Skip to content
article

System design primer: design a rate limiter, with real numbers

Requirements, back-of-the-envelope maths, four algorithms compared, and the Redis part everybody hand-waves.

Nikhil Sharan@nikhil_twopointers

September 24, 2026 · 5 min read

Most rate limiter answers I hear in mocks are one box on a diagram labelled "rate limiter" and then the candidate moves on. That box is the whole question. Let us do it properly, with numbers. Thanks to Kenji for reviewing the draft.

1. Requirements (spend 5 minutes here)

  • Public API behind a gateway. 10 million daily active users.
  • Limit: 100 requests per user per minute. Some endpoints (login, OTP) much stricter.
  • Peak traffic: 50,000 requests per second across all users.
  • Many gateway nodes, so the limit must be shared, not per node.
  • The check must add less than 5 ms to a request.
  • If the limiter itself fails: fail open for normal reads, fail closed for login and OTP.

Ask about that last point in the interview. It shows you think about failure, and the answer changes per endpoint.

2. Back-of-the-envelope (5 more minutes)

Say 2 million users are active in any given minute.

  • Fixed window counter: one key per user per minute. Key around 30 bytes, value 8 bytes, Redis overhead around 50 more. Call it 100 bytes. 2M × 100 B = about 200 MB. Fits one Redis node easily.
  • Sliding window log: store every request timestamp in a sorted set. Up to 100 entries per user, and a sorted set entry costs around 60+ bytes in practice. 2M × 100 × 64 B = about 12.8 GB. Ouch.
  • Throughput: 50k checks per second. One Redis node handles 100k+ simple operations per second, so one primary works, but with no headroom. Shard by user id across 3 nodes for headroom and failure isolation.

That 12.8 GB number is the moment a good candidate changes their mind about the design. Interviewers love seeing the maths change your decision.

3. The algorithms

Fixed window counter

INCR a key like rl:user123:202609241405, set EXPIRE 60 on first use, reject when the count passes 100. Simple and cheap. The problem: a user sends 100 requests at 14:05:59 and 100 more at 14:06:00. That is 200 requests in 2 seconds, and both windows are happy.

Sliding window log

ZADD the timestamp, ZREMRANGEBYSCORE everything older than 60 seconds, ZCARD to count. Exact. And we just calculated why it is too expensive at this scale.

Sliding window counter

Keep counters for the current and previous minute, and weight the previous one by how much of it still overlaps the last 60 seconds. Example: we are 40 seconds into the current minute. Previous minute had 80 requests, current has 30. Estimate = 30 + 80 × (20 / 60) = 56.7. Under 100, allowed. Two small counters per user, roughly 400 MB total, and no boundary burst. Approximate, but very good in practice.

Token bucket

Each user has a bucket with capacity 100 that refills at 100/60 tokens per second. A request takes a token. It allows short bursts up to the capacity, which most real APIs want. You store two numbers per user: tokens and last refill time. The catch: read, refill, take and write must happen atomically, or two gateway nodes will both see "1 token left" and both allow the request.

4. The Redis part people hand-wave

Atomic means a Lua script. Redis runs the whole script without interleaving other commands.

-- KEYS[1] = bucket key, e.g. rl:{user123}
-- ARGV: capacity, refill rate (tokens/sec), now (ms), cost
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now

local elapsed = math.max(0, now - ts) / 1000
tokens = math.min(capacity, tokens + elapsed * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) * 2)
return { allowed, tostring(tokens) }

Notes on this script:

  • Redis converts Lua numbers to integers when returning them, which is why tokens goes back as a string.
  • now comes from the gateway, so clock skew between gateways matters. You can call redis.call('TIME') inside the script instead, so every gateway shares one clock.
  • PEXPIRE removes idle users. After capacity / rate seconds an idle bucket is full again anyway, so the key carries no information.

5. Where it runs and what the client sees

  • As middleware in the API gateway, before any business logic.
  • Rejected requests get 429 Too Many Requests with a Retry-After header, plus remaining-quota headers so well-behaved clients can slow down by themselves.
  • A Redis round trip inside the same zone is under a millisecond, well inside our 5 ms budget.
  • Log every 429 with the key and the rule that fired. You will need it the first time a big customer complains.

6. How I would split 45 minutes

  1. Requirements: 5 minutes
  2. Numbers: 5 minutes
  3. Algorithm choice with trade-offs: 10 minutes
  4. Deep dive (atomicity, the script, sharding): 15 minutes
  5. Failure modes and monitoring: 5 minutes
  6. Buffer for the interviewer's follow-ups: 5 minutes

Another mock worth watching for how the candidate scopes requirements before designing anything:

For further reading, the system-design-primer repository on GitHub is still the best free starting point.

Per request: one Redis call, about 1 ms. Memory: around 200 to 400 MB for 2 million active users with counters or buckets, versus about 12.8 GB with a sliding log.

Written by

Nikhil Sharan

I teach DSA and system design on YouTube (Two Pointers, 1.2M). Ex-SDE at two product companies, now teaching full time. Dry run everything. No paid course here, only free stuff and honest opinions.

Get the best of #interview_horror weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

17

Sign in to join the conversation.

Sign in to comment
Kenji Tanabe@kenjitanabe#4 in Interview Horror Stories
I reviewed the draft, so I'm biased, but the 12.8 GB line is the moment most candidates should pivot and most don't. Interviewers love seeing a number change your mind.
7 net votes
Danny Cho@danny_cho_la3 rep in Interview Horror Stories
Strongly agree on fail open vs fail closed per endpoint. One more: put a small local token bucket in each gateway process in front of Redis. If Redis gets slow, the local bucket stops abusive clients from turning a Redis brownout into an outage.
11 net votes
Nikhil Sharan@nikhil_twopointers#1 in Interview Horror Stories
Two-tier limiting, yes. Local approximate plus global exact. I should have written that, adding a note.
7 net votes
Marcus Johnson@marcus_oncall1 rep in Interview Horror Stories
From the on-call side: whatever you build, log every 429 with the key and the rule that fired. The first incident will be a big customer behind one NAT IP getting throttled as "one user". Ask me how I know.
15 net votes
Arjun Mehta@arjunmehta1 rep in Interview Horror Stories
In payments we rate limit OTP sends per phone number AND per device AND per IP, and the per phone one fails closed, always. An open OTP endpoint for 3 minutes is a big SMS bill and a fraud ticket.
6 net votes
Ankit Khurana@faang_or_bust#9 in Interview Horror Stories
solid. most youtubers draw a box called "rate limiter" and move on, this actually has the lua. still say token bucket is the only answer interviewers want
10 net votes
Kunal Bhatia@kunal_dsa#2 in Interview Horror Stories
Did faang_or_bust just call a Nikhil post solid. Screenshotting this.
6 net votes
Ankit Khurana@faang_or_bust#9 in Interview Horror Stories
dont make it weird
2 net votes
Mahin Hasan@mahin_cp#5 in Interview Horror Stories
q: why PEXPIRE capacity/rate * 2? just so idle users' keys disappear?
10 net votes
Nikhil Sharan@nikhil_twopointers#1 in Interview Horror Stories
Yes. After capacity/rate seconds an idle bucket is full again, so the key carries no information. Times 2 is just a safety margin.
11 net votes
Omkar Joshi@omkar_java4 rep in Interview Horror Stories
We built a fixed window limiter for a bank's statement download API and the boundary burst hit us exactly as you describe. Users discovered they could refresh at :59 and :00. Moved to the sliding counter, problem gone.
6 net votes
Sneha Kulkarni@laidoff_not_out#3 in Interview Horror Stories
frontend person here and i understood all of it. the 5 / 5 / 10 / 15 time split is the most useful part for me, i always run out of time drawing boxes
10 net votes
Vikram Iyer@vikram_on_h1b3 rep in Interview Horror Stories
Small but important for anyone on Redis Cluster: every key a script touches must hash to the same slot. Here it's one key so you're fine, but people add a second key for a global limit and get CROSSSLOT errors. Use hash tags like {user123}, which you already did in the comment.
4 net votes
Nikhil Sharan@nikhil_twopointers#1 in Interview Horror Stories
Very good point, thank you Vikram. CROSSSLOT is a rite of passage.
4 net votes
Kelly Zhao@kel_zhao3 rep in Interview Horror Stories
saving for my first ever system design round lol. some new grad loops have a "lite" design round now and i have been pretending they don't exist 😭
8 net votes
Rahul Deshmukh@rahul_d_jobhunt#5 in Interview Horror Stories
SDET angle: how do you test this? I'd want a load test that fires 101 requests in a minute from 50 parallel clients for the same key and checks that exactly 100 pass. Race conditions show up fast if someone skipped the Lua.
10 net votes
Nikhil Sharan@nikhil_twopointers#1 in Interview Horror Stories
That is exactly the test, and a great thing to say in an interview. Testing atomicity is how you prove you understand why the Lua is there.
11 net votes