{"slug": "one-hundred-phones-one-wifi-ip-rate-limiting-a-room-instead-of-a-user", "title": "One hundred phones, one WiFi IP: rate limiting a room instead of a user", "summary": "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.", "body_md": "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.\n\n[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.\n\nWe 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.\n\nThen 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.\n\nThe number is now 1200, and more usefully, the reasoning is written down where the number is:\n\n```\n/**\n * global: 1200 requests per minute per IP.\n *\n * Sized for the worst legitimate case: an entire pub behind one WiFi exit IP\n * with the WebSocket server unreachable, so every phone is on the polling\n * fallback. 100 players x 6 polls/min = 600, plus joins, page loads and answer\n * submissions. The previous 300 was sized for the join burst alone and so\n * 429'd the whole venue precisely when the fallback kicked in.\n */\nglobal: sliding(1200, 60),\n```\n\nThe 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.\n\nThe global limiter is a bot guard. The real protection is per endpoint, and the choice of key is the whole design:\n\n```\njoinSession:   sliding(100, 60),   // per IP\nplayerPageLoad:sliding(60, 60),    // per IP\nsubmitAnswer:  sliding(10, 60),    // per participantId\nsessionPoll:   sliding(30, 60),    // per participantId\nauth:          sliding(10, 900),   // per IP\n```\n\nLook 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.\n\nThat 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:\n\nIf the entity you are protecting *from* and the entity you are keying *on* are not the same, your limiter punishes bystanders.\n\nFor 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.\n\nThe 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.\n\n```\ntry {\n  const { success } = await ratelimit.global.limit(ip)\n  if (!success) { /* 429 */ }\n} catch {\n  // Redis unavailable, fail open so the app stays up\n}\n```\n\nRate 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:\n\n``` js\nfunction makeNoopLimiter() {\n  return { limit: async (_id: string) => ({ success: true as const }) }\n}\n```\n\nNo Redis in dev, no ceremony, no docker-compose to run before you can log in.\n\nThis one cost an evening and it is embarrassing in the good way, where the root cause is obvious the moment you see it.\n\nBrowsers 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:\n\n`/too-many-requests`.\nThe 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:\n\n``` js\nconst RATE_LIMITED_PATH = '/too-many-requests'\nif (request.nextUrl.pathname !== RATE_LIMITED_PATH) {\n  // ...check the limit\n}\n```\n\nThe 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.\n\nUpstash'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.\n\n```\nanalytics: false,\n```\n\nMiddleware 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.\n\nA 429 has two audiences with different needs:\n\n``` js\nconst isHtmlRequest = request.headers.get('accept')?.includes('text/html')\n```\n\nBrowsers 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.\n\nAll 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.", "url": "https://wpnews.pro/news/one-hundred-phones-one-wifi-ip-rate-limiting-a-room-instead-of-a-user", "canonical_source": "https://dev.to/daniel_pertu/one-hundred-phones-one-wifi-ip-rate-limiting-a-room-instead-of-a-user-3i30", "published_at": "2026-09-15 19:07:37+00:00", "updated_at": "2026-09-15 19:19:19.668222+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["pub-trivia.app", "Upstash"], "alternates": {"html": "https://wpnews.pro/news/one-hundred-phones-one-wifi-ip-rate-limiting-a-room-instead-of-a-user", "markdown": "https://wpnews.pro/news/one-hundred-phones-one-wifi-ip-rate-limiting-a-room-instead-of-a-user.md", "text": "https://wpnews.pro/news/one-hundred-phones-one-wifi-ip-rate-limiting-a-room-instead-of-a-user.txt", "jsonld": "https://wpnews.pro/news/one-hundred-phones-one-wifi-ip-rate-limiting-a-room-instead-of-a-user.jsonld"}}