Chapter 4 — Overload Protection
Rate limits, backpressure and load shedding: staying useful when demand is bigger than capacity
Prerequisites: Chapter 1 — Timeouts and Deadlines, Chapter 3 — Isolation and Blast Radius. Deep chapters this one summarises: SD 2 Chapter 17 — Rate Limiting, SD QA Q31 — Rate limiting algorithms, Q32 — Backpressure, Q63 — Design a rate limiter. Retries and jitter: JS Chapter 7, Part 6.
How to read this chapter
Overload means requests arrive faster than you can finish them. It happens on good days (a launch, a flash sale, a viral post) and bad days (a retry storm, a client bug, a dependency at half capacity). The interviewer's version is short: "traffic just tripled". A design that only scales out has no answer for the minutes before the new capacity arrives, or for the database that doesn't scale out at all.
There are three tools, and they act in different places:
| Tool | Acts on | Question it answers |
|---|---|---|
| Rate limiting | One client or tenant, at the edge | Is this caller over its share? |
| Backpressure | The stage before you, inside your system | Can I make the producer slow down? |
| Load shedding | Your own queue, at the server | Which work do I refuse so the rest finishes in time? |
Rate limiting and backpressure are covered deeply elsewhere, so Parts 2 and 3 are summaries with a sim each. Load shedding is thin elsewhere, so it gets most of the chapter, with a Node experiment that measures goodput under eight different queue policies. All numbers below come from that run (Node 22, one machine).
| Term | Meaning |
|---|---|
| Capacity | The most work per second the system can finish within its latency target |
| Throughput vs goodput | Work finished per second / work finished in time to be useful per second. A response after the client gave up is throughput, not goodput |
| Admission control | Deciding at the door whether to accept a request at all |
| Queue timeout | The longest a request may wait before it is dropped |
| Standing queue | A queue that never empties. It adds delay to every request and absorbs no burst |
| CoDel | Controlled Delay: a queue policy that watches how long requests wait, not how many are waiting, and drops aggressively when the wait never falls below a target |
| Adaptive LIFO | Serve the newest request first, but only while the queue is standing |
| Criticality | A label on each request saying how much it matters, so the least important is shed first |
| 429 / 503 | "You are over your limit" / "I can't serve anyone right now" |
| Metastable failure | The system stays down after the trigger has gone, because something the failure created (a backlog, retries) keeps it overloaded |
Part 1: What overload does to a queue
Take a server with 5 database connections and 50 ms per query. Its capacity is 5 ÷ 0.05 = 100 requests a second. Send it 200 a second. Each second, 100 more requests join the queue than leave it. After 5 seconds there are 500 waiting, and a new request waits 500 ÷ 100 = 5 s before its query starts.
Now add a client timeout of 1 s, which every real client has. Once the queue holds more than 100 requests, every request waits more than 1 s, so every client gives up before its answer is ready. The server doesn't know. It keeps taking requests off the front of the queue, running their queries and writing responses to closed sockets. The database is 100% busy, throughput is 100 a second, and goodput is zero.
Measured, with 80 req/s for 5 s, then 200 req/s for 10 s, then 80 req/s again, and a plain FIFO queue with no limit:
| Window | Offered | Goodput | What happened |
|---|---|---|---|
| 0–5 s | 80/s | 80/s | p50 53 ms |
| 5–10 s | 200/s | 37/s | 814 of 1,000 clients timed out |
| 10–15 s | 200/s | 0/s | all 1,000 timed out |
| 15–20 s | 80/s (below capacity) | 0/s | all 400 timed out |
| 20–25 s | 80/s | 0/s | all 400 timed out |
77% of all queries the server ran (1,944 of 2,535) were for clients who had already left. And look at the last two rows: load dropped below capacity at 15 s, and goodput stayed at zero. The queue held about 1,000 abandoned requests, and spare capacity was only 20 a second, so it would take nearly a minute just to reach the live requests. That is a metastable failure: the overload has ended, and the backlog it created keeps the system down. Add client retries, and the backlog never drains at all.
Part 2: Rate limiting (summary)
SD 2 Chapter 17 and Q31 cover the algorithms and the distributed implementation. In the room, be ready to say:
- Why: abuse, fairness between clients, and protecting capacity. Each needs a different key: IP, API key, tenant, endpoint.
- Algorithms in one line each. Fixed window: cheap, lets up to 2× through at the boundary. Sliding window log: exact, one entry per request. Sliding window counter: two counters, close enough, what most large systems use. Token bucket: a steady rate with bursts up to the bucket size, the usual choice for APIs. Leaky bucket: a queue that drains at a fixed rate, smooth output with added latency.
- Distributed: per-pod counters let a client through N times the limit. Use a shared counter (Redis
INCR/EXPIREor a Lua token bucket) or limit at the gateway. - The response:
429withRetry-After, plus remaining-quota headers on successful responses so well-behaved clients slow down early. - Fail open or closed when the limiter's store is down. Fail open (allow, with a rough per-process limit as a backstop) for ordinary API traffic, so the limiter can't take the API down. Fail closed for endpoints where abuse costs money or safety: login attempts, OTP sends, LLM generation billed to you.
- A rate limit is not overload protection. A thousand clients each under their limit can still exceed your capacity. Limits protect you from one caller; shedding protects you from all of them together.
Try the burst: a quiet client fires 40 requests in one second against three limiters set to 10/s.
The token bucket lets the saved-up burst through and the windows don't. Which is right depends on whether your backend can absorb a burst of 20; if it can't, the bucket size is the number to change.
Part 3: Backpressure (summary)
Q32 covers it in depth. The points to make:
- Backpressure is the consumer slowing the producer down, so buffers stay small. An unbounded queue hides overload until memory runs out; a bounded one shows it.
- When a bounded queue is full, you either block the producer (backpressure: the producer waits) or reject (load shedding: the work is refused). Blocking only works when the producer can wait and has its own bound, otherwise you have moved the pile-up one stage back.
- In Node:
writable.write()returnsfalsewhen the stream's buffer is past itshighWaterMark; stop writing until'drain'.stream.pipeline()does this for you (JS Chapter 9). For job queues, a worker'sconcurrencyis the backpressure on processing, and the producer should check queue depth (BullMQ'sgetWaitingCount(), or acount(*)on a Postgres queue) and reject or defer new work past a threshold. - At the edge, you cannot slow the clients down. HTTP has no way to make a browser wait politely. Backpressure ends at your front door; past it, the only tools are rate limits and shedding.
In the sim, producers send 30 messages a second to consumers that manage 20. Predict, then after the reveal switch When full between No cap, Reject and Block.
With no cap, the age of the oldest message climbs without limit. With a cap, it levels off, and the excess is either rejected or held by the producer. Either way, the queue stops lying about how far behind you are.
Part 4: Load shedding in depth
Load shedding is refusing work you can't finish in time, early and cheaply, so the work you do accept finishes in time. Every policy below was run against the same server (capacity 100 req/s) and the same load (200 req/s for 10 s, client timeout 1 s, a quarter of requests marked critical).
The results first
Steady overload, 10–15 s:
| Queue policy | Goodput | p50 of successes | Rejections (503) | Client timeouts | Critical requests served | Queries for clients already gone (whole run) |
|---|---|---|---|---|---|---|
| FIFO, no limit | 0/s | — | 0 | 1,000 | 0/250 | 1,944 |
| FIFO, skip if client hung up | 0/s | — | 0 | 1,000 | 0/250 | 922 |
| FIFO, drop if it can't finish before its deadline | 90/s | 991 ms | 508, slow (p50 964 ms) | 40 | 138/250 | 23 |
| Bounded queue of 20, reject when full | 99/s | 249 ms | 507, in 2 ms | 0 | 182/250 | 0 |
| LIFO, no limit | 102/s | 57 ms | 0 | 488 | 111/250 | 313 |
| LIFO + deadline drop | 102/s | 59 ms | 0 | 484 | 115/250 | 0 |
| CoDel-style queue timeout + adaptive LIFO | 100/s | 57 ms | 502, in 66 ms | 0 | 97/250 | 0 |
| Priority: sheddable may queue only behind 5, critical behind 40 | 99/s | 99 ms | 506, in 2 ms | 0 | 250/250 | 0 |
And once load fell back to 80/s (15–20 s): plain FIFO stayed at 0/s; skip-if-hung-up recovered to 76/s; every other policy served 80/s at once.
Each row teaches one idea. The rest of this part goes through them.
Admission control: decide at the door
The cheapest place to refuse a request is before you've done anything for it. Admission control looks at a signal and decides accept or reject on arrival:
| Signal | Good for | Watch out for |
|---|---|---|
| Requests in flight (a concurrency limit) | Most services; directly bounds memory and pool use | Must be sized from capacity, not guessed |
| Queue length | Simple, cheap | A length means different waits at different speeds; convert with capacity |
| Queue wait time | The best direct measure of "too late" | Needs timestamps per request |
| Event loop delay (Node) | CPU-bound Node services; perf_hooks.monitorEventLoopDelay() gives a histogram, and @fastify/under-pressure sheds on it | Lags behind a sudden spike |
| CPU % | Rarely the right signal | A service waiting on its database is overloaded at 20% CPU |
Rejection must be much cheaper than service. Reject before authentication lookups, body parsing and database calls; better still, at the load balancer or gateway. A server that spends 30% of its effort on each request it rejects still collapses, just later.
Sizing a bounded queue
A queue is only useful for absorbing bursts. Size it by how long you're willing to make someone wait: queue length = acceptable wait × capacity. With capacity 100/s and a 200 ms budget for waiting, that's 20. The run confirms it: the bounded queue of 20 had a p50 of 249 ms (200 ms waiting plus a 50 ms query), zero timeouts, and its rejections took 2 ms, so a client could retry elsewhere straight away.
A queue of 1,000 at the same capacity means a 10 s wait, which is longer than any client will stay.
Deadlines: drop work that's already too late
Requests should carry a deadline, the time after which the answer is useless (Chapter 1). Then the server can check it when it takes a request off the queue:
function pump() {
while (active < LIMIT && queue.length) {
const job = queue.shift();
if (Date.now() + EXPECTED_MS > job.deadline) { // can't finish in time
reject(job.res, 503, 'past deadline');
continue;
}
start(job);
}
}
The two FIFO rows show why the check must be about time left, not "is the client still there". Skipping requests whose client had already hung up still gave zero goodput: the requests at the front of the queue were always about 990 ms old, still connected, and timed out during their 50 ms query. Checking now + expected work > deadline fixed that: 90/s goodput, only 23 wasted queries.
But look at its latency: p50 991 ms. The queue stayed full of requests just young enough to pass the check, so every success waited almost the whole deadline, and the rejected ones also waited about 960 ms before being told no. A deadline check alone keeps goodput up and lets latency sit at the deadline. You want the queue short as well.
Two practical points:
- Node cannot see time spent in the kernel's accept queue or in the load balancer. If you need the true arrival time, have the proxy add it as a header (nginx can set one from
$msec; some platforms' routers add one, such as Heroku'sX-Request-Start), or propagate the caller's deadline. - Pass the deadline down. A downstream that knows the caller gives up in 300 ms can refuse work it can't do in time, instead of finishing it for nobody.
LIFO under overload, and CoDel
FIFO is fair when the queue is short and the worst possible order when it's long: it always serves the request that has waited longest, which is the one most likely to have given up. LIFO serves the newest request, which still has its whole deadline. In the run, LIFO took goodput from 0 to 102/s with a p50 of 57 ms.
The cost is in the other columns. Under LIFO the oldest requests are never reached while overload lasts: 484 clients waited the full second and timed out without any answer, and with no deadline check the queue kept 673 dead requests at the end of the run. LIFO decides who loses; it doesn't tell them quickly.
CoDel (Controlled Delay) comes from network routers. Its insight is to measure the queueing delay, not the queue length, and to tell a burst from a standing queue: a burst drains, so the queue is empty at some point; a standing queue never is. Applied to a server (Facebook described this in "Fail at Scale", ACM Queue, 2015, together with adaptive LIFO):
- If the queue has been empty at least once in the last 100 ms, it's absorbing a burst. Use a generous queue timeout (500 ms) and FIFO.
- If it hasn't, it's a standing queue. Switch to a short queue timeout (60 ms) and LIFO.
let lastEmpty = Date.now();
const standing = () => Date.now() - lastEmpty > 100;
setInterval(() => { // sweep every 5 ms
const now = Date.now();
if (queue.length === 0) { lastEmpty = now; return; }
const maxWait = standing() ? 60 : 500;
for (let i = queue.length - 1; i >= 0; i--) {
if (now - queue[i].arrived > maxWait) { reject(queue[i].res, 503, 'queue timeout'); queue.splice(i, 1); }
}
if (queue.length === 0) lastEmpty = now;
}, 5);
// in pump(): const job = standing() ? queue.pop() : queue.shift();
Result: 100/s goodput, p50 57 ms, zero timeouts, zero wasted queries, the queue never above 13, and every rejected client heard "no" in about 66 ms. In normal operation it behaves like a plain FIFO queue, so you don't pay for it on good days.
Priority shedding
All of the above treat every request the same. They shouldn't. Under overload, a checkout matters more than a "people also viewed" call, and a user's request matters more than a background sync. Label each request with a criticality and shed from the bottom.
In the run, a quarter of the traffic (50/s) was critical, well inside the capacity of 100/s. The adaptive policy, which ignores priority, served only 97 of 250 critical requests; the bounded queue served 182. The priority policy let sheddable requests wait only while fewer than 5 were queued, and critical ones up to 40:
function admit(job) {
if (active < LIMIT) return start(job);
const room = job.critical ? 40 : 5;
if (queue.length >= room) return reject(job.res, 503, 'overloaded');
queue.push(job);
}
It served 250 of 250 critical requests, at the same total goodput. Shedding by priority doesn't save more work; it chooses which work survives.
How to do it in a real system:
- Few classes, set by the caller. Google's SRE book describes four levels (
CRITICAL_PLUS,CRITICAL,SHEDDABLE_PLUS,SHEDDABLE). Two or three are enough for most products: user-waiting-critical, user-waiting-optional, background. - Propagate it. A checkout calling inventory passes "critical" along, so inventory sheds the right work too. A header is enough.
- Decide it before the incident. The list of what's core and what's optional is a product decision (Chapter 6). Writing it during the outage is too late.
- Watch for priority inflation. If every team marks its calls critical, nothing is. Make "critical" need a reason and review it.
- Background work sheds first, and it can wait in a durable queue instead of being dropped.
503 or 429?
429 Too Many Requests | 503 Service Unavailable | |
|---|---|---|
| Means | This client is over its limit | The server can't serve requests right now, from anyone |
| Whose capacity | The client's quota | Yours |
| Sent by | The rate limiter | Load shedding, maintenance, a dependency down |
Retry-After | When the quota will refill | Short, so clients spread out (and they should add jitter) |
| What the client should do | Slow down; retrying sooner just gets more 429s | Retry with backoff and a retry budget, ideally on another instance |
| Your SLO | Usually doesn't count against it: the client broke its contract | Counts: it's your availability |
| gRPC equivalent | RESOURCE_EXHAUSTED | UNAVAILABLE |
Two traps. A shed request must not fail the health check: if the load balancer sees 503s from /health, it removes the instance and moves its load to the rest, which then shed more and get removed in turn (Debugging Chapter 13, Case 7). Keep /health outside the shedder. And don't send 429 for your own overload: clients that read 429 as "you did something wrong" may stop retrying entirely, or page their own on-call for a bug that isn't theirs.
The client's half: don't retry into an overloaded server
Every retry is more load on a server that already has too much. Three client rules (Chapter 2):
- Honour
Retry-After, and add jitter on top. - Retry budget: retries may add at most about 10% to the request rate. When a server is overloaded, most retries fail, so the budget runs out and retries stop.
- Client-side throttling: Google's SRE book describes clients that track, over the last couple of minutes, how many requests they sent and how many the backend accepted, and reject locally with probability
max(0, (requests − K × accepts) / (requests + 1)), with K around 2. When the backend is rejecting most requests, the client stops sending most of them, so rejections stop costing the backend anything.
Part 5: Protecting the database
The database is usually the last component to scale and the first to saturate. Overload protection for it has its own list:
- Pool size is admission control. 30 pods × a pool of 20 = 600 connections, and Postgres's default
max_connectionsis 100. Each Postgres connection is a separate server process, and past a point, more connections make total throughput lower, not higher. Size pools from what the database can do: total connections across the fleet ≤ what it handles well, with a reserve for admin and migrations. A pooler such as PgBouncer in transaction mode lets many clients share fewer real connections. - A pool wait is a queue: give it a timeout.
connectionTimeoutMillisinpgdefaults to 0 (wait forever). Set it to a few hundred milliseconds, so a saturated database turns into fast 503s instead of an ever-growing wait. statement_timeoutper role, so one runaway query can't hold a connection for an hour.- Separate pools per workload (Chapter 3): reporting and backfills never share the API's connections.
- Limit by cost. An export that scans 9 million rows costs as much as ten thousand page loads. Give expensive endpoints their own concurrency limit.
- Reads: cache and serve stale on overload rather than fall through to the database (Chapter 6).
- Writes: smooth bursts through a queue that the database drains at its own pace, with a bound on the queue (Part 3).
- No retries at the database layer when it's saturated. Retry at one layer, higher up, with a budget.
When the Interviewer Breaks It
Say the answer out loud before opening each one.
Break 1: "Traffic just went to 3× for a flash sale"
Answer
"Autoscaling will help in a few minutes, and the database won't scale at all, so the first minutes are about protecting goodput. At the door, a concurrency limit per instance sized to what the database can take, and a short queue: about 200 ms of waiting, so roughly 20 requests at 100 per second per instance. Past that, 503 with a short Retry-After, in a couple of milliseconds. Requests carry a deadline, and anything that can't finish before it is dropped before it touches the database. Checkout and payment are critical and get a deeper queue; recommendations, reviews and browsing history shed first. I'd watch goodput, checkout success rate and queue wait, not CPU."
Follow-up: "Why not just queue everything and serve it slowly?" Because each client gives up after a second or two. A long queue makes the server finish work for people who have left: in the measured FIFO run, 77% of the database's work was for clients who had already timed out, and goodput was zero.
Weak answer: "Add more servers." It doesn't help in the first minute, and more API servers send more load to the same database.
Break 2: "Your Redis-based rate limiter is down"
Answer
"Every request now fails its rate-limit check, so the question is fail open or fail closed. For normal API traffic I fail open, so the limiter can't take the whole API down, with a per-process in-memory limit as a rough backstop: if the global limit is 1,000 per tenant and there are 10 pods, each allows about 100. For endpoints where abuse costs money or safety, like login attempts, OTP sends, and LLM generation, I fail closed. And the server's own load shedding still protects capacity, because rate limiting was never what kept us up under total load."
Follow-up: "How does the request path avoid waiting on a dead Redis?" A tight timeout on the limiter call, around 5–10 ms, plus a circuit breaker so you stop trying for a while. Otherwise every request pays the connection timeout.
Weak answer: "Requests fail until Redis is back." The limiter has become a single point of failure for the whole API.
Break 3: "The database is at 100% CPU and p99 is 8 seconds"
Answer
"First, stop adding work: cap the pool wait with a short connectionTimeoutMillis so requests fail in milliseconds instead of queueing, and check that nothing retries database calls. Then find out if it's demand or one workload: pg_stat_statements and database time by endpoint and tenant. If one report or tenant dominates, kill or limit it. If it's broad demand, shed sheddable endpoints at the API, serve cached or stale reads, and pause background jobs. Longer term: separate pools and replicas for reporting, a statement timeout, total connections sized to what the database handles well, and cost-based limits on expensive endpoints."
Follow-up: "Would you raise max_connections?" No. More connections means more processes competing for the same CPU, and throughput usually goes down. If clients need more connections, put a pooler in front.
Weak answer: "Scale up the database instance." It may be the eventual fix, but a resize takes time and often a failover, and it does nothing about the queue you have now.
Break 4: "The spike ended ten minutes ago and the system is still down"
Answer
"That's a metastable failure: something the overload created is keeping it overloaded. Usually it's one of two things. A backlog of dead work, where a long FIFO queue is full of requests whose clients have left, so the server works on them first. Or retries, where every timeout comes back as one to three new requests. To get out: drop the backlog by flushing the queue or restarting with admission control on; cut retries with a budget or by shedding hard at the edge for a minute; then let traffic back in gradually. To prevent it: bounded queues with deadline checks, LIFO or CoDel when the queue is standing, and retry budgets. In the measured run, plain FIFO stayed at zero goodput after the load fell below capacity, while every policy with a bound or a deadline recovered at once."
Follow-up: "How would you see this on a dashboard?" Throughput high, goodput near zero; queue wait far above the client timeout; database busy with error rates at the clients. CPU alone looks like the system is "working hard".
Weak answer: "Restart everything." It may clear the backlog, but if retries are the sustaining cause, the restarted fleet gets the same storm.
Break 5: "Should your shedder return 429 or 503?"
Answer
"503. 429 means this client is over its quota, which is the client's problem, and well-behaved clients slow down and may not retry. 503 means we're overloaded, which is our problem: it counts against our availability, and clients should retry with backoff to another instance. Both carry Retry-After. And the health check must never return the shedder's 503, or the load balancer will remove overloaded instances one by one and push their load onto the rest."
Follow-up: "A tenant is over their plan's limit and the server is also overloaded. Which code?" 429 for that tenant, because it tells them what to fix; the server's overload is handled by shedding the requests that are within quota by priority.
Weak answer: "Either works, it's just a status code." Clients, SLOs and load balancers all treat them differently.
The Causal Chains
- If a queue has no bound and the client has a timeout
- If load drops below capacity but the queue still holds abandoned requests
- If the server only skips requests whose client has disconnected
- If you drop past-deadline requests but keep a long queue
- If the queue serves FIFO during a standing overload
- If all requests are shed equally
- If shedding makes
/healthreturn 503 - If every pod has a pool of 20 and you run 30 pods
Checklist: overload protection for any design
- State capacity in numbers (per instance and for the database) and the client timeouts you're designing for.
- Rate limit per tenant or API key at the edge, with a shared counter; decide fail open or closed per endpoint.
- A concurrency limit per instance, and a bounded queue sized by acceptable wait × capacity.
- Requests carry a deadline; the server checks time left against expected work when it dequeues, and passes the deadline downstream.
- A queue policy for standing overload: queue timeout plus LIFO (CoDel-style), so old requests are dropped quickly.
- Criticality labels, propagated with each request, shedding sheddable work first.
- Rejection is cheap and early: before auth lookups, body parsing and database calls, or at the gateway.
503+Retry-Afterfor overload,429for quota;/healthstays outside the shedder.- Clients: honour
Retry-After, jitter, a retry budget, one retry layer. - Database: fleet-wide connection total under what it handles well, pool wait timeout,
statement_timeout, separate pools per workload, cost-based limits. - Dashboards show goodput and queue wait, not only throughput and CPU.
What's next
Overload protection keeps one service useful when demand exceeds capacity. Chapter 5 — Replication and Failover handles a different break: the machine holding your data dies, and something else has to take over without losing or doubling writes.