DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Ten rate limiters, and the only hard question was what to key each one on

Rate limiting tutorials spend all their effort on the algorithm. Sliding window versus token bucket, how to make the Redis call atomic, where to put the headers. In practice we found the algorithm was the easy part. We used a sliding window everywhere and never thought about it again.

The part that actually caused an outage was the key.

The room is the problem

Our app is a live pub quiz. A hundred people in a venue join on their own phones, over the venue's WiFi. From the server's point of view, that is one hundred distinct humans arriving from one IP address within about ninety seconds of each other.

Every default you have ever copied off a blog post is IP keyed. Every one of those defaults is wrong here, and wrong in the worst direction: the limit does not throttle an abuser, it throttles a room full of paying customers at the exact moment they are trying to start.

So the limiter config in our codebase reads less like a security setting and more like a list of decisions about blame. Here is the whole thing, with the key each one uses.

Limiter Budget Keyed on
global (middleware) 1200 / min IP
joinSession 100 / min IP
submitAnswer 10 / min participantId
playerPageLoad 60 / min IP
sessionPoll 30 / min participantId
auth (login, signup, OAuth) 10 / 15 min IP
passwordReset 5 / 15 min IP
hostAction 30 / min userId
pricing API 20 / min IP
stripeCheckout 5 / 10 min userId

Three groups, three different answers.

Keyed on IP, sized for a venue

joinSession at a hundred per minute looks absurdly generous next to the five per minute you would put on a signup form. It is sized for the literal worst legitimate case: the quizmaster says "scan the code on your table" and a hundred people scan it at once.

A bot hammering the join endpoint from one address still gets stopped, because a bot does not stop at a hundred, it does a thousand. The limit is doing its job, it is just calibrated against the real traffic shape instead of an imagined one.

playerPageLoad at sixty per minute per IP is the same reasoning with a smaller number, because it fires once per scan rather than once per join attempt.

The auth limiters go the other way. Ten logins per fifteen minutes per IP is tight, because credential stuffing is the threat and there is no legitimate scenario where one address needs eleven. Password reset is tighter again at five, because the failure mode there is not just brute force, it is using your app as a free email flooder aimed at somebody else's inbox.

Keyed on the participant, because the room is not a user

Two limiters are keyed on the individual player instead, and both of them are corrections.

sessionPoll guards the WebSocket fallback. When the socket is unreachable, each phone falls back to polling a Server Action for the current game state every ten seconds. Six polls a minute per phone.

We originally keyed that on IP. Do the arithmetic: a hundred phones, six polls each, six hundred requests a minute from one address, against a limit meant to describe one client. The fallback path, the thing that exists specifically to keep the quiz alive when the socket dies, became a denial of service attack that the venue performed on itself. The degraded mode took the whole room offline at exactly the moment it was needed.

Keying it on the participant makes it mean what it was always supposed to mean, which is "no single phone may poll faster than this":

const snapshot = await getSessionStateSnapshot(
  sessionId,
  participantId ?? `${role}:${sessionId}`,
)
Enter fullscreen mode Exit fullscreen mode

submitAnswer is keyed the same way, ten per minute per participant. Worth noting what that limit is and is not doing: a player only ever gets one accepted answer per question, and that is enforced by a unique constraint in Postgres, uq_answers_participant_question. The rate limiter is not the correctness guarantee. It only has to leave room for retries and for the handful of questions that can pass inside a sixty second window.

If your rate limit is load bearing for correctness, you have a missing constraint somewhere.

Keyed on the user, because they are logged in

hostAction and stripeCheckout key on the authenticated user id, which is the easy case and the one every tutorial assumes. The host fires "launch question" and "close question" roughly once a minute, so thirty per minute only catches a runaway loop. Checkout is five per ten minutes because the cost of exceeding it is junk objects accumulating in Stripe, not a security incident.

Two things that went wrong around the edges

The global limit was sized for the wrong event. It started at three hundred per minute per IP, which was sized for the join burst. It did not account for a hundred phones on the polling fallback at the same time. 1200 is the number after actually adding up the worst legitimate minute: joins, page loads, answer submissions, and every phone degraded at once.

The error page was rate limited too. This one is beautiful. A browser that trips the global limit gets redirected to /too-many-requests. That path went through the same middleware, tripped the same limit, got redirected to itself, and the user saw ERR_TOO_MANY_REDIRECTS instead of the polite explanation we had written. Worse, every hop burned another token, so the window never drained. The fix is one line:

if (request.nextUrl.pathname !== RATE_LIMITED_PATH) {
  // ...check the limit
}
Enter fullscreen mode Exit fullscreen mode

If you have a friendly 429 page, exempt it.

The default we settled on

Before writing the limit, write down who is hurt when it trips. If the answer is "everyone who happens to be on the same WiFi as the offender", you have the wrong key, and no amount of tuning the number will fix it.

If you want to see the shape of traffic that forced all of this, the QR code joining page describes what a hundred simultaneous players actually do to a server, including the section on what happens when the venue WiFi is bad. Or start a free session at pub-trivia.app, no card needed, and join it from a few devices at once to watch the join burst from the other side.

Top comments (0)