One hundred phones, one WiFi IP: rate limiting a room instead of a user The developer behind pub-trivia.app described how a global rate limiter of 300 requests per minute per IP caused an entire pub quiz venue to be throttled when the WebSocket server went down and all phones fell back to polling through a single NAT exit IP. The team raised the global limit to 1200 and moved to per-endpoint limits keyed on participant IDs for polls and answer submissions, reserving IP-based keys for joins and auth. The writeup argues limiters should be sized for degraded conditions and should fail open when their datastore is unavailable. Most rate limiting advice assumes an IP is roughly a person. Build software for a pub quiz and that assumption dies on the first Tuesday night: a hundred phones scan a QR code within the same thirty seconds, and every one of those requests leaves the building through one NAT exit IP. pub-trivia.app https://pub-trivia.app runs quiz nights in venues. Here is what we got wrong, and the three rules that came out of fixing it. We started with a global middleware limiter of 300 requests per minute per IP. It was sized for the join burst: a hundred players, a few requests each, spread over a minute. Fine. Then the WebSocket server became briefly unreachable during a deploy, every phone fell back to polling, and the venue went dark. The polling fallback is what is supposed to save the night, and instead it was the thing that consumed the budget and served a 429 to the whole room. The number is now 1200, and more usefully, the reasoning is written down where the number is: / global: 1200 requests per minute per IP. Sized for the worst legitimate case: an entire pub behind one WiFi exit IP with the WebSocket server unreachable, so every phone is on the polling fallback. 100 players x 6 polls/min = 600, plus joins, page loads and answer submissions. The previous 300 was sized for the join burst alone and so 429'd the whole venue precisely when the fallback kicked in. / global: sliding 1200, 60 , The general form of the mistake: you sized the limit for the happy path, but limits only ever bind during the unhappy one. Ask what your client does when your infrastructure is degraded, multiply by your worst realistic room, and size for that. The global limiter is a bot guard. The real protection is per endpoint, and the choice of key is the whole design: joinSession: sliding 100, 60 , // per IP playerPageLoad:sliding 60, 60 , // per IP submitAnswer: sliding 10, 60 , // per participantId sessionPoll: sliding 30, 60 , // per participantId auth: sliding 10, 900 , // per IP Look at sessionPoll . A well-behaved phone polls every ten seconds, so it uses six per minute, and 30 is generous. Key that on IP instead and you have not built a per-client limit at all, you have built a limit on the room : the fifth phone to poll exhausts a shared budget and the other ninety-five are throttled for something they did not do. That is not a tuning error, it is a category error, and it converts your own protection into a denial of service against your own customer. The heuristic I would write on the wall: If the entity you are protecting from and the entity you are keying on are not the same, your limiter punishes bystanders. For answers and polls the entity is a participant, and we have a participant id, so we use it. For joins we do not have one yet, by definition, so IP is the only option and the limit is sized for the room: 100 per minute, which lets an entire venue join at once while still stopping a single script hammering the endpoint. The floor under submitAnswer being as low as 10 is worth a note: a player only gets one accepted answer per question anyway, enforced by a unique constraint on participant id, question id . The limiter only has to leave headroom for retries and for the few questions that can pass inside one window. The database is the correctness boundary, the limiter is a cost boundary, and confusing the two leads to limits that are either useless or cruel. try { const { success } = await ratelimit.global.limit ip if success { / 429 / } } catch { // Redis unavailable, fail open so the app stays up } Rate limiting is availability protection. A rate limiter that takes the app down when its own datastore blips has inverted its purpose. Every limiter here degrades to "allow" rather than "deny", including in local development where the Upstash env vars are simply absent: js function makeNoopLimiter { return { limit: async id: string = { success: true as const } } } No Redis in dev, no ceremony, no docker-compose to run before you can log in. This one cost an evening and it is embarrassing in the good way, where the root cause is obvious the moment you see it. Browsers that trip the global limiter get redirected to a friendly /too-many-requests page instead of a raw JSON 429. The page was, naturally, subject to the same middleware. So: /too-many-requests . The user sees ERR TOO MANY REDIRECTS , not the apology page. Worse, every hop consumed another token from a sliding window, so the window never drained and the loop was self-sustaining. The fix is one condition: js const RATE LIMITED PATH = '/too-many-requests' if request.nextUrl.pathname == RATE LIMITED PATH { // ...check the limit } The general rule: your error surface must be exempt from the error condition. True for rate-limit pages, true for login pages behind auth gates, true for a status page hosted on the thing whose status it reports. Upstash's rate limiter has an analytics option. It writes an extra record on every limit call, which doubles the command count on the hottest path in the app, in exchange for a dashboard nothing in our repo reads. analytics: false, Middleware also never awaits the returned pending promise, so on a serverless platform that write was liable to be torn down mid-flight anyway. Paying twice for data that may not arrive is not a trade, it is a leak. A 429 has two audiences with different needs: js const isHtmlRequest = request.headers.get 'accept' ?.includes 'text/html' Browsers get the redirect to a page that explains, in English, what happened and when to try again. Everything else gets JSON and a Retry-After: 60 header, because a fetch call cannot read an apology. All of the above exists so that a hundred people in a room can scan a code and start playing at the same moment. If you want to watch that part work, pub-trivia.app https://pub-trivia.app has a free tier with no card required: create a session, open the join link on a couple of devices, and the limits above are what is sitting quietly underneath it.