August 24, 2026 · 3 min read
A rate limiter that does not limit
Rate limiting on the site was written as an ordinary in-memory counter: a map, a key of «action plus IP hash», a time-based reset. Such code works, is verified in a minute, and looks right.
On a single server it is right. The problem appears when the site runs on a serverless platform.
There is no «single server» there. Every request lands in a function instance, and the platform starts as many as it likes and kills them when it likes. Each has its own memory. A limit of «thirty messages a minute» actually means «thirty per live instance», and how many there will be is not up to the site.
Then it gets unpleasant. Under normal load there is one instance and the limiter works — every test passes. Under real load, which is exactly when you need it, there are many instances and the limit falls apart. The protection collapses at the single moment it was written for.
Worse still, the comment above such code usually says honestly «enough for one server». Written, and forgotten, because the platform was chosen later.
The fix is simple: the counter must be shared. Redis over HTTP fits perfectly — no library, no persistent connection, works in edge runtimes too.
Two details that are easy to get wrong.
First, set the key expiry only on creation. Refresh it on every request and the window never closes for whoever is hammering you.
Second, what to do when the store does not answer. You cannot let everyone through: a Redis outage would become an open door. You cannot fail the request either. The right answer is to fall back to the in-memory counter — weaker, but present.
export async function rateLimit( key: string, limit = 8, windowMs = 60_000,): Promise<RateLimitResult> { const url = envString("UPSTASH_REDIS_REST_URL"); const token = envString("UPSTASH_REDIS_REST_TOKEN"); if (url && token) { const result = await inRedis(url, token, `rl:${key}`, limit, windowMs); /* Redis не ответил — считаем в памяти, а не пропускаем всех. Отказ хранилища не повод снимать защиту, но и повесить сайт из-за него нельзя: в памяти предел слабее, но он есть. */ if (result) return result; } return inMemory(key, limit, windowMs);}