# One hundred phones, one WiFi IP: rate limiting a room instead of a user

> Source: <https://dev.to/daniel_pertu/one-hundred-phones-one-wifi-ip-rate-limiting-a-room-instead-of-a-user-3i30>
> Published: 2026-09-15 19:07:37+00:00

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.
