Rate limiting for solo builders who have never thought about it
作者:Seaworthy · 2026年6月12日
Photo by Ludovic Charlet on Unsplash
Your project has ten users. You know all of them. Why would you bother with rate limiting?
Here is why: because those ten users are not the only people who can reach your API. Anyone with a URL can hit it. Bots scan the whole public internet constantly, and when they find an unauthenticated or weakly authenticated endpoint, they hammer it. Not maliciously, often. Just automatically.
I have seen a side project rack up $400 in OpenAI API bills overnight because a single endpoint was both unprotected and hooked up to an LLM. The builder had six real users. The bill came from exactly one IP address in Ashburn, Virginia.
What actually happens without rate limiting
The boring case: someone writes a script that calls your /api/contact form 10,000 times and you get 10,000 emails. Your inbox is full. Your Resend or SendGrid quota is gone.
The expensive case: every request triggers an LLM call, a database write, or an external API call you pay per-use for. One determined person, or one misconfigured scraper, can empty your account.
The sneaky case: credential stuffing. An attacker has a list of 500,000 email/password pairs from old breaches, and your login endpoint will happily attempt all 500,000 with no friction whatsoever. This is how accounts get compromised at small apps nobody thinks is a target.
None of these require a sophisticated attacker. A teenager with a for loop in Python can do all of them.
The fix is not complicated
Rate limiting works by tracking how many requests a given identifier (usually an IP address, sometimes a user ID) has made in a sliding window, and returning a 429 Too Many Requests response when they exceed the limit.
The actual implementation for most apps is about ten lines.
Express
import rateLimit from 'express-rate-limit'
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
})
app.use('/api/', limiter)
That uses express-rate-limit, which stores state in memory by default. Fine for a single-process app. If you run multiple instances, you need a Redis store, but worry about that when you get there.
Next.js API routes
Next.js does not have a built-in rate limiter, so you pick one. The Upstash @upstash/ratelimit package is what most people reach for because it works in Edge runtime and connects to a managed Redis instance with a free tier.
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { NextRequest, NextResponse } from 'next/server'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
})
export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
// your actual handler logic
}
One thing to note: Upstash creates an in-memory ephemeral cache that sits in front of Redis. If you delete keys directly in Redis to unblock something, the block may persist until the process restarts. So when you are debugging, restart the container, not just the Redis key.
Hono
Hono has a built-in rate limiter middleware that ships with the library:
import { Hono } from 'hono'
import { rateLimiter } from 'hono/rate-limiter'
const app = new Hono()
app.use(
'/api/*',
rateLimiter({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-6',
keyGenerator: (c) => c.req.header('x-forwarded-for') ?? 'unknown',
})
)
Clean. No extra dependencies.
Things people get wrong
The IP address is a proxy for identity, not identity itself. Behind a corporate NAT, all your users might share one IP. A limit of 100 requests per 15 minutes will block your entire company. For authenticated routes, rate limit on user ID, not IP. For unauthenticated ones, IP is the best you can do, but set the threshold high enough that normal browser use is impossible to trigger.
The x-forwarded-for header is set by the proxy in front of your app, but it can also be set by the client. If you are not behind a trusted reverse proxy, an attacker can spoof it to bypass IP-based limits. Trust x-forwarded-for only when you control the infrastructure in front of your app.
429 responses should include a Retry-After header. Legitimate clients, automated or otherwise, will back off and retry. Scrapers usually do not care, but it is good practice and it is in the spec.
You do not need a perfect solution
The goal is not to stop a nation-state actor. The goal is to make abuse expensive enough that it moves on to someone easier. A basic rate limiter does that. A free Redis instance from Upstash or Railway is enough. You can ship this in an afternoon.
The projects that get hit are the ones with zero friction. Add a little friction. Your LLM bill will thank you.
Seaworthy flags missing rate limiting on API routes as part of its resilience checks.
本文由 AI 生成,总结了公开可用的来源。
Seaworthy 会扫描你的代码仓库,发现类似本文提到的各种问题。
安全漏洞、泄露的密钥和配置错误——在部署前捕获。免费运行,无需账户。