Module 8 / Rate limiting

Module 8 · lesson 5 of 7 · Read 10 min

Limits across many servers

One shared count in Redis, and keeping it fast.

Everything so far has quietly assumed one server holding all the counters in its memory. Tiffin hasn't looked like that since Module 4. It runs eight stateless app servers behind a load balancer that sends each request to whichever server is free.

Put a token bucket in each server's memory and see what happens. A customer's requests are spread across all eight servers. Each server sees only an eighth of them, and each server has its own full bucket. A limit of "5 a second" quietly becomes "40 a second". Add servers for a busy Saturday and the limit grows again, without anyone touching it.

This is the same problem you met with sessions in Module 4: state that lives in one server's memory stops being true when there are many servers. The fix is similar too.

Three ways to share a limit

Approach How it works Accuracy Cost per request
Split the limit Each of N servers enforces limit ÷ N locally Rough None
Shared counter Every server checks one counter in Redis Exact One network round trip
Local with sync Count locally, sync totals to Redis every second Close, small overshoot Almost none

Split the limit

The cheapest idea: with 8 servers and a limit of 300 a minute, each server allows 37 a minute. No shared state at all.

It works only if the load balancer spreads each client's requests perfectly evenly, which it doesn't. A customer who happens to hit server 3 a lot gets blocked at 37 while servers 1 to 8 together saw 60. Worse, the number has to change every time you add or remove a server. Fine as a rough seatbelt (nginx's per-IP limit on each nginx instance works this way), not as a real per-user limit.

A shared counter in Redis

The usual answer is to keep the counters somewhere every server can reach. Tiffin already has Redis from Module 3, and Redis is a good fit: in memory, single-threaded (so each command runs alone), and fast.

%% caption: Every server asks the same Redis, so a user's budget is the same wherever their request lands.
flowchart TD
  LB[Load balancer] --> S1[Server 1] & S2[Server 2] & S8[Server 8]
  S1 & S2 & S8 --> R[("Redis<br/>one counter per user")]:::hot
Every server asks the same Redis, so a user's budget is the same wherever their request lands.

Fixed window, and a nasty race

A fixed window in Redis looks like two commands:

const key = `rl:${userId}:${Math.floor(Date.now() / 60000)}`
const count = await redis.incr(key)       // creates the key at 1 if missing
if (count === 1) await redis.expire(key, 60)
if (count > 300) return reject()

INCR is atomic, so two servers can never both see the same count. Good. But look at the gap between INCR and EXPIRE. If the server crashes, is restarted by a deploy, or loses its connection right there, the key is created without an expiry. Here the key changes every minute so a stuck key just wastes memory. But many people write the simpler version with a fixed key like rl:42, and then a stuck key means that user stays at their count forever. Once they hit 300, they're blocked until someone deletes the key by hand.

The fix is to send both commands as one unit that runs completely or not at all:

MULTI
INCR rl:42:29341522
EXPIRE rl:42:29341522 60 NX
EXEC

MULTI/EXEC runs the commands together with nothing in between. NX on EXPIRE (Redis 7 and later) only sets the expiry if the key doesn't have one, so the window isn't pushed back on every request.

Token bucket, and the read-then-write race

A token bucket is harder. The logic is "read tokens and time, compute, write back". If two servers do that at the same moment for the same user:

%% caption: Both servers read before either writes, so both see the last token.
sequenceDiagram
  autonumber
  participant S1 as Server 1
  participant R as Redis
  participant S2 as Server 2
  S1->>R: read user 42's bucket
  R-->>S1: 1 token left
  S2->>R: read user 42's bucket
  R-->>S2: 1 token left
  S1->>R: allow, write 0
  S2->>R: allow, write 0
Both servers read before either writes, so both see the last token.

Two requests allowed with one token. Under a burst from a script, this happens all the time, and the limit leaks.

Any check-then-update on a shared counter must happen in one atomic step, or two servers will both see the same "last token" and both spend it.

Redis lets you run a small Lua script atomically: while it runs, no other command touches Redis. The whole token bucket fits in one:

-- KEYS[1] = bucket key, ARGV[1] = capacity, ARGV[2] = refill per second
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000

local b = redis.call('HMGET', KEYS[1], 'tokens', 'last')
local tokens = tonumber(b[1]) or capacity
local last = tonumber(b[2]) or now

tokens = math.min(capacity, tokens + (now - last) * rate)
local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'last', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 1)
return allowed

Two details worth noticing:

  • The time comes from Redis (TIME), not from the app servers. Eight servers have eight slightly different clocks. One clock in one place means refills are computed consistently.
  • The key expires once the bucket would be full again. A missing key is treated as a full bucket, so deleting idle buckets changes nothing and keeps memory down to active users only. With capacity 50 and 5 per second, that's 11 seconds.

The servers load the script once and call it by its hash, so each request is one round trip.

Is Redis fast enough?

Back-of-the-envelope for Tiffin at the dinner peak:

  • About 3,600 requests a second reach the app. Say each does 1.5 limit checks on average (the general limit, plus a feature limit on some).
  • That's about 5,400 Redis calls a second. A single Redis instance handles tens of thousands of small scripts a second. Plenty of room.
  • In the same data centre, a round trip to Redis takes roughly 0.2 to 0.5 ms. A typical Tiffin request takes 30 to 80 ms. The limiter adds about 1%.
  • Memory: say 200,000 active buckets at about 100 bytes each is 20 MB. Nothing.

So for Tiffin, one shared Redis is the right answer. Two things to keep it that way: give limits their own Redis instance, and keep a short timeout on the call. Sharing the cache's instance means a cache flush, or the cache's eviction policy throwing out keys under memory pressure, quietly resets everyone's counters.

When Redis is down

Now the uncomfortable question. Redis has a blip at 8:15pm. Every limit check fails. What should the server do?

  • Fail open: allow the request. Customers keep ordering. For a few minutes there's no rate limiting.
  • Fail closed: reject the request. Nothing gets abused, and nothing gets ordered either.

For most of the API, fail open. A rate limiter that takes Tiffin down because the limiter itself broke has done more damage than any scraper. But fail open safely: keep a simple in-memory limiter on each server (the "split the limit" approach) as a fallback, so a flood still meets some limit while Redis is away.

For a few endpoints, the answer flips. Sending OTPs costs money and invites abuse. If Redis is down, fall back to a strict local limit, or fail closed with a "try again in a minute" message. Losing a few logins for a minute is cheaper than an SMS bill.

And set a short timeout, say 5 to 10 ms, on the Redis call. A Redis that is slow, not dead, is worse than one that's down: every request waits for it.

Local counters with periodic sync

Sometimes even one Redis call per request is too much: a very high-volume API, or a single hot key like a huge partner sending 20,000 requests a second through one counter.

Then flip it around. Each server counts locally and, once a second, sends its total to Redis (INCRBY key delta) and reads back the global total. Between syncs, each server decides on its own, using the last global total plus its own local count.

The price is overshoot. Between syncs, each server can't see what the others allowed. If every server assumes the whole remaining budget is its own, 8 servers could each allow 1,000 in the same second against a limit of 1,000 a second. The usual fix is to let each server spend only its share of what's left (remaining ÷ 8) until the next sync. Then the overshoot is limited to roughly one sync interval of uneven traffic, and it corrects itself at the next sync. For a "protect our capacity" limit, that's often fine. For "5 OTP guesses", it's not, so those stay on exact Redis checks.

Counting across machines is its own big topic: likes, stock levels and seat counts face the same choice between exact-but-slow and fast-but-approximate. Module 12 covers it in general. Module 19 shows sketches like count-min that track approximate counts for millions of keys in tiny memory, useful for spotting the heaviest clients.

Find the bug

A teammate writes this limiter for "3 OTPs per phone number per 10 minutes", shared across all servers:

const key = `otp:${phone}`
const sent = Number(await redis.get(key) ?? 0)
if (sent >= 3) return reject()
await redis.set(key, sent + 1, 'EX', 600)
await sendSms(phone)

Find two problems and fix them.

Show one answer
  1. Race. GET then SET is check-then-update in two steps. An attacker firing 50 requests at once across 8 servers will have many of them read sent = 0 or 1 before anyone writes, so far more than 3 SMS go out. Use one atomic step: INCR the key (with the expiry set in the same MULTI, or in a Lua script) and check the returned value.
  2. The window keeps moving. Every SET ... EX 600 resets the 10 minutes, and rejected attempts don't, so the behaviour is hard to reason about. With EXPIRE ... NX the window starts at the first OTP and stays put.

Fixed:

MULTI
INCR otp:+919800000000
EXPIRE otp:+919800000000 600 NX
EXEC

If the returned count is over 3, reject and don't send. Bonus: decide what happens when Redis is down. For OTP, fail closed or fall back to a strict local limit.

What to remember

  1. In-memory limits on N servers quietly become N times the limit, and change whenever you scale. Shared limits need shared state.
  2. A shared counter in Redis is the usual answer. It costs one round trip (about 0.5 ms in the same data centre) and one Redis handles far more than Tiffin needs.
  3. Check-then-update must be atomic. Use INCR with MULTI/EXEC for windows, and a Lua script for token buckets. Never leave a key without an expiry.
  4. Take time from one clock (Redis's TIME), and let idle buckets expire.
  5. Decide what happens when Redis fails: fail open with a local fallback for normal traffic, fail closed or strict for money and security endpoints. Keep the timeout short.
  6. For very high volume, count locally and sync every second, accepting a small, bounded overshoot.