Rate-limit an API to 100 requests per minute
- Backend
- 45 min
- medium
- Node.js · TypeScript
The brief — 45 minutes
Our public API is being hammered by a handful of clients. Add a per-key limit of 100 requests per minute in front of it — correct under concurrency, correct across several app instances, and honest with the caller about what it did. Start from an empty middleware file and say what you are trading away as you go.
Requirements
- Limit by API key: 100 requests per rolling minute, not per calendar minute.
- Reject over-limit requests with
429and aRetry-Afterheader. - Every response carries the
RateLimit-*headers so a client can back off before it is cut off. - Correct with several app instances behind a load balancer — the counter cannot live in process memory.
- Two simultaneous requests on the 100th slot must not both succeed.
- If the store is unreachable, the API stays up — say which way you fail and why.
Stage 1 of 5 · ~5 min
Pin down what "100 per minute" means
Rolling or fixed, per key or per IP, and what happens on failure
"100 requests per minute" is three unanswered questions, and the interviewer is waiting to see whether you notice.
- Rolling or fixed window? A fixed calendar minute lets a client send 200 requests across the boundary — 100 at 11:59:59 and 100 at 12:00:00. The requirement says rolling, so the naive counter is already ruled out.
- Keyed by what? An API key is stated. Ask what happens to unauthenticated traffic — usually a second, tighter limit by IP, which is a different bucket, not the same one.
- Fail open or fail closed? If the store is unreachable, do you let everything through or reject everything? For a public read API, fail open — an outage in the limiter should not become an outage in the product. For a payments endpoint the answer flips.
- Is the limit per instance or global? Stated as global, which is what forces a shared store; say it anyway, because it is the constraint that decides the design.
Stage 2 of 5 · ~10 min
Pick the algorithm out loud
Fixed window, sliding log, or token bucket — and why
Name the three candidates and reject two of them on the record. This is the part of the round that is really being marked.
| Algorithm | Memory per key | Boundary burst | Notes |
|---|---|---|---|
| Fixed window counter | One integer | Up to 2× the limit | Cheapest; wrong for this requirement |
| Sliding log | One timestamp per request | None | Exact, but 100 entries per key and a sorted-set trim on every call |
| Sliding window counter | Two integers | Bounded, small | Weights the previous window by how far into the current one you are |
| Token bucket | Two numbers | Configurable burst | The right answer when a burst is a feature |
For a rolling minute with a small memory budget, the sliding window counter is the honest pick: two fixed buckets and a weighted sum.
// requests allowed ≈ current + previous * (1 - elapsed_fraction_of_window)
const WINDOW_MS = 60_000;
const LIMIT = 100;
function windowKey(apiKey: string, now: number) {
return `rl:${apiKey}:${Math.floor(now / WINDOW_MS)}`;
}Say the approximation out loud: it can admit slightly more than 100 in a pathological traffic shape, in exchange for O(1) memory per key. That trade is the answer.
Stage 3 of 5 · ~15 min
Make the counter atomic
One round trip that cannot interleave
Read-then-write is the bug this stage exists to avoid. Two requests that both read 99 both
write 100, and 101 requests get through. The fix is one round trip that the store executes
without interleaving — a Lua script in Redis.
-- KEYS[1] current window, KEYS[2] previous window
-- ARGV[1] limit, ARGV[2] weight (0..1), ARGV[3] ttl seconds
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local previous = tonumber(redis.call('GET', KEYS[2]) or '0')
local used = current + math.floor(previous * tonumber(ARGV[2]))
if used >= tonumber(ARGV[1]) then
return {0, used}
end
local now = redis.call('INCR', KEYS[1])
if now == 1 then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
end
return {1, used + 1}export async function middleware(req: Request, res: Response, next: NextFunction) {
const apiKey = req.header("x-api-key");
if (!apiKey) return next(); // a different limiter's problem
const now = Date.now();
const elapsed = (now % WINDOW_MS) / WINDOW_MS;
const [allowed, used] = (await redis.eval(
SCRIPT,
2,
windowKey(apiKey, now),
windowKey(apiKey, now - WINDOW_MS),
String(LIMIT),
String(1 - elapsed),
String(Math.ceil((WINDOW_MS * 2) / 1000)),
)) as [number, number];
// headers and the 429 come next
}Two details worth narrating: the EXPIRE is set only when the counter is created, so a busy
key doesn't have its TTL pushed forever; and the TTL is two windows, because the
previous bucket still has to be readable while the current one is live.
Stage 4 of 5 · ~10 min
Answer the caller properly
429, Retry-After, and the RateLimit headers
A limiter that returns a bare 429 makes every client guess. The headers are the interface.
const remaining = Math.max(0, LIMIT - used);
const resetSeconds = Math.ceil((WINDOW_MS - (now % WINDOW_MS)) / 1000);
res.setHeader("RateLimit-Limit", LIMIT);
res.setHeader("RateLimit-Remaining", remaining);
res.setHeader("RateLimit-Reset", resetSeconds);
if (!allowed) {
res.setHeader("Retry-After", resetSeconds);
return res.status(429).json({
error: "rate_limited",
message: `Limit of ${LIMIT} requests per minute exceeded.`,
retryAfter: resetSeconds,
});
}
next();Three things that separate this from a first draft: the headers go on every response,
not just the rejection, so a well-behaved client can slow down before it is cut off;
Retry-After is in seconds and matches RateLimit-Reset rather than being an invented
constant; and the body is a machine-readable code plus a human message, because both a
retry loop and a developer read it.
Stage 5 of 5 · ~5 min
Failure modes and what you would do next
What happens when Redis is down, and what you traded away
The last five minutes are about what you have not built.
- Redis is down. Wrap the
evalin atryand fail open, with a metric and a log — the decision you wrote as a constant in stage one. Do not fail open silently; an outage that removes your limits without telling anyone is how the next incident starts. - Clock skew between instances. Every instance derives its bucket from its own
Date.now(), so a badly skewed node lands in a different window. NTP is the real fix; the honest alternative is to take the timestamp from Redis (TIME) at the cost of a round trip. - Hot keys. One abusive client is a single Redis key, which is a single shard. Say the words: for a large deployment you shard the key space or move to a local token bucket with periodic reconciliation, and accept looser accuracy.
- What is deliberately missing. Per-plan limits, burst allowance, and exemptions — all
of them are a lookup that replaces the
LIMITconstant, which is why the constant is a constant and not an inline100.
Once you finish
The follow-on asks that arrive when you are done early.
- Different limits per plan, resolved from the key.
- A burst allowance on top of the sustained rate.
- Exempt health checks and internal callers without a second code path.
- Emit a metric per rejection so the limit can be tuned rather than guessed.
What this tests
- api-design
- concurrency
- caching
- failure-modes
- observability