Leer en español · Lire en français · Auf Deutsch lesen
Section 9.3 of 9.8 · Intermediate · 29 min read
Asynchrony, queues and back pressure
Video lesson
5 min · narrated, with captionsListen only (audio)
Read the transcript
Welcome to Section nine point three. Load never arrives evenly. This lesson is about what a system should do when work arrives faster than it can be done.
Think of a busy bakery at opening time. It hands out numbered tickets, so customers wait in an orderly line and the bakers work at a steady pace. That's a queue. But a line can only be so long before the bread runs out, so a good bakery stops handing out tickets when the line is too long. It serves pre-orders first, and limits each customer to a sensible number of loaves. And it tells latecomers honestly to come back in ten minutes, rather than letting them wait for nothing.
Here the order API puts work onto a queue, and several identical workers take it off. Each message goes to only one worker. These are called competing consumers. When arrivals spike above what the consumers can handle, the extra work waits in the queue as a backlog. The workers keep going at their steady rate, and drain the backlog after the spike. So consumers can be sized for the average, not the peak. The price is delay. A backlog is simply a wait that every item behind it will experience. Fine for an email, unacceptable for a payment the customer is watching.
If a worker crashes before acknowledging a message, another worker gets it. So delivery is at least once, and consumers must be idempotent: processing a message twice must be safe. A queue absorbs a temporary excess, never a permanent one. An unbounded queue grows until it exhausts memory, or holds work for hours after anyone cares. So give every queue a bound. Little's law turns the wait you're willing to accept into a maximum length. And measure the age of the oldest message as well as the length. Age is what users experience.
Let's size a queue. Concert tickets go on sale at ten o'clock. Forty workers each take two hundred and fifty milliseconds per order, so they handle a hundred and sixty orders a second. The forecast is six hundred a second for two minutes. That leaves fifty two thousand eight hundred orders waiting, and the last one waits five and a half minutes. The promise was an answer within two minutes. To keep that promise, Little's law says the queue can hold at most a hundred and sixty times a hundred and twenty: nineteen thousand two hundred orders. More workers? The payment provider allows only three hundred requests a second, so seventy five workers at most. The wait becomes exactly two minutes. Met only just, with no margin. But there are twenty five thousand tickets, at two and a half per order: only ten thousand orders can succeed. Queueing the rest only delays the inevitable sold out. So the team uses seventy five workers, a waiting room, and a queue bounded at twelve thousand: the stock plus twenty per cent for failed payments. Then new orders hear sold out at once.
Back pressure is any way a slower stage makes a faster one slow down. Here the producer sends five hundred items a second, the consumer handles three hundred, and the bounded queue is full. You can block the producer, so it waits for space. That suits pipelines and batch jobs. You can reject new work, failing fast with a four twenty nine or five oh three, so the client may retry later. Or you can drop items, such as the oldest or the lowest priority. Each is right somewhere. The mistake is not choosing.
A server that accepts everything under overload spends its whole capacity on requests whose clients have already given up. Goodput, the requests completed in time to be useful, collapses towards zero. Load shedding rejects some requests deliberately. It must be early and cheap, before any expensive work. It should be prioritised. In the food delivery platform, checkouts and courier locations outrank recommendations, which outrank analytics events. And it should be aware of time: a request that has waited longer than its caller's timeout should be dropped, not processed.
Rate limiting shares capacity fairly between clients. The usual algorithm is the token bucket. This bucket holds at most twenty tokens, and refills at five tokens a second. Each request, counted per API key, must take a token. If a token is left, the request is forwarded. If not, the answer is four twenty nine, Too Many Requests, ideally with a Retry-After header. So a quiet client can burst: thirty requests at once see twenty forwarded and ten rejected. But in any sixty seconds it can send at most twenty plus five times sixty: three hundred and twenty requests.
The full section compares rate limiting algorithms and covers dead-letter queues. Next, in Section nine point four, we look at the patterns that stop one failure spreading. See you there.
What you need to know
- A queue between producer and consumer turns a spike into a backlog the consumers drain at their own rate, letting them be sized for the average; the cost is delay for every item behind the backlog.
- Competing consumers scale background work by adding workers; delivery is at least once, so consumers must be idempotent, and visibility timeouts and prefetch must suit processing times.
- A queue cannot absorb a sustained excess; bound every queue using Little's law (maximum length = processing rate × acceptable wait) and monitor the age of the oldest message as well as the length.
- Back pressure makes faster stages slow down; when a bounded queue is full the system must block the producer, reject new work or drop some work, and should choose deliberately.
- Load shedding rejects some work early and cheaply, by priority and by age, so that goodput stays high under overload.
- Rate limiting shares capacity per client: the token bucket allows bursts up to its capacity and a steady refill rate, the leaky bucket smooths output, and fixed windows allow double bursts at boundaries.
- Answer limits with 429 (or 503 when the whole server is overloaded) plus Retry-After, and send repeatedly failing messages to an owned, monitored dead-letter queue.
Queues turn spikes into backlogs
Load never arrives evenly. Lunch orders bunch at 12:30, ticket buyers all press the button at 10:00:00, and a batch job dumps a million events at midnight. A synchronous service must either have capacity for the highest peak or fail during it. Putting a queue between the producer of work and the service that does it changes the arithmetic: during a spike the extra work waits in the queue, and the consumers process it at their own steady rate, catching up when the spike has passed. Microsoft's catalogue of cloud patterns calls this queue-based load levelling. Section 2.4 showed why queues form whenever arrivals bunch; this section is about using them deliberately.
The benefit is real: consumers can be sized for the average rather than the peak, a slow or restarting consumer does not lose work, and the producer is no longer temporally coupled to the consumer (Section 6.5). The price is that the work is now done later. A queue turns a spike into a backlog, and a backlog is simply a delay that every item behind it will experience. That is fine for sending confirmation emails or updating a search index, questionable for assigning a courier to an order, and unacceptable for a payment the customer is waiting to see confirmed. The first design question for any queue is therefore: how long may an item wait, and what happens to items that would wait longer?
A queue absorbing a spike
Work queues and competing consumers
The most common use is a work queue with competing consumers: several identical workers read from one queue, each message goes to only one of them, and throughput grows by adding workers. It is the X axis of the scale cube applied to background work, and it scales naturally on the right signal: backlog per worker, or the age of the oldest message.
The mechanics matter for correctness. A worker takes a message, processes it and then acknowledges it; if the worker crashes first, the broker makes the message visible again after a timeout (SQS calls it the visibility timeout) and another worker processes it. Delivery is therefore at least once, so consumers must be idempotent (Section 6.4). A visibility timeout shorter than the real processing time causes the same message to be processed twice in parallel. Ordering is lost as soon as there is more than one consumer, unless messages are partitioned by key, in which case the number of partitions caps the number of useful consumers. And prefetching (a worker taking several messages at once to save round trips) should be kept low for slow tasks, or one worker hoards messages that idle workers could have processed.
A queue does not create capacity
A queue absorbs a temporary excess; it cannot absorb a permanent one. If work arrives faster than it is completed for long enough, the backlog grows without limit, and Little's law (L = λ × W) says the waiting time grows with it. An unbounded in-memory queue eventually exhausts memory and crashes the process, losing everything in it; an unbounded durable queue fills its disk or keeps work for hours after anyone cares about it. Worse, the queue hides the problem: producers get fast acknowledgements, error rates stay at zero, and the only symptom is that results arrive later and later. Engineers at Amazon describe such backlogs as potentially insurmountable, because a service that falls far enough behind may spend all its capacity processing work that its clients have long since given up on.
Two practices follow. First, measure the age of the oldest message as well as the queue length: age is what users experience, and it reacts immediately when consumers stall. Second, give every queue a bound derived from the waiting time you are willing to accept, using Little's law, and decide in advance what happens when the bound is reached. That decision is the subject of the next three parts of this section.
Worked example
Worked example: sizing the order queue for a ticket on-sale
Tickets for a stadium concert go on sale at 10:00. Order submissions go onto a queue; workers authorise payment and allocate seats, each taking 250 ms. The forecast is 600 submissions per second for the first two minutes. The promise to customers is an answer within two minutes. There are 25,000 tickets and the average order is for 2.5 tickets.
| Worker capacity with 40 workers | 40 ÷ 0.25 s = 160 orders per second |
|---|---|
| Backlog after the two-minute peak | (600 − 160) × 120 s = 52,800 orders waiting |
| Wait for the order at the back (Little's law, W = L ÷ λ) | 52,800 ÷ 160 = 330 s, five and a half minutes: the promise is broken |
| Longest queue that keeps the promise | L = λ × W = 160 × 120 = 19,200 orders |
| More workers? | The payment provider's contract allows 300 requests per second, so at most 75 workers (300 orders per second). The backlog becomes (600 − 300) × 120 = 36,000 and the wait 36,000 ÷ 300 = 120 s: the promise is met only just, with no margin |
| What the stock allows | 25,000 ÷ 2.5 = 10,000 orders can succeed. At 600 per second that many arrive in under 17 seconds; queueing the other 62,000 or so orders only delays the inevitable 'sold out' |
| Decision | 75 workers; a waiting room in front of the order page admits 300 shoppers per second; the order queue is bounded at 12,000 (the stock plus 20% for failed payments); once reservations cover the stock, new submissions are answered at once with 'sold out, join the waiting list' instead of joining the queue |
Little's law turns a promise about waiting time into a maximum queue length. Any queue allowed to grow beyond it is a broken promise that has not been announced yet.
The downstream rate limit, not the number of workers the platform could start, set the real capacity, so adding consumers had a hard ceiling.
The best bound on a queue is often a business limit: there is no point holding work that cannot possibly succeed.
Back pressure: making producers slow down
Back pressure is any mechanism by which a slower downstream stage makes a faster upstream stage slow down, instead of letting work pile up between them. The term comes from fluid in pipes, and the idea was met in pipes and filters (Section 5.4). It is built into many layers of the stack you already use. TCP's flow control stops a sender when the receiver's buffer is full. A bounded blocking queue makes the producer's put wait for space. Pull-based consumers, as in Kafka, take work only when they are ready, so the broker's log rather than the consumer's memory holds the backlog, and a Kafka producer blocks or fails when its own send buffer is full. The Reactive Streams specification lets a subscriber request a specific number of items, so a stream never delivers more than the subscriber has asked for.
Back pressure works well inside one process or along one pipeline. Across a request-response boundary with human users it usually becomes rejection: an overloaded HTTP service cannot make a phone wait politely, so it answers 429 Too Many Requests or 503 Service Unavailable, ideally with a Retry-After header, and relies on the client to back off. When a bounded queue is full there are only three choices: block the producer, reject the new work, or drop something already accepted. Each is right somewhere; the mistake is not choosing.
Three responses to a full queue
Choosing what happens when a queue is full
| Strategy | Behaviour | Suits | Risk |
|---|---|---|---|
| Block the producer | Producer waits until there is space | Pipelines and batch jobs where the producer can pause | Blocked producers hold threads and connections; across services this spreads the slowdown upwards |
| Reject new work | Fail fast with an error such as 429 or 503 | Interactive requests; the client can retry, wait or show a message | Clients that retry immediately turn rejection into more load (Section 9.4) |
| Drop oldest | Discard the item that has waited longest | Telemetry, location updates, live prices: newest data is most valuable | Silent loss; must be counted and visible |
| Drop by priority | Discard the least important items first | Mixed workloads: keep checkouts, drop analytics events | Low priorities may starve; priorities must be assigned honestly |
| Spill to durable storage | Overflow to disk or a durable queue | Work that must eventually happen but can wait hours | Just a larger queue: still needs a bound and a drain-time estimate |
Load shedding and prioritisation
A server under more load than it can handle does worse than serving its capacity and refusing the rest. It accepts everything, every request waits in its internal queues, response times rise past the clients' timeouts, and it ends up spending its whole capacity on requests whose clients have already given up. Throughput stays high while goodput, the rate of requests completed in time to be useful, collapses towards zero. Load shedding prevents this by rejecting some requests deliberately so that the rest succeed.
Good shedding has three properties. It is early and cheap: rejecting a request must cost far less than serving it, so it happens at the front door, before authentication-heavy or database work, based on a cheap signal such as in-flight requests or queue age. It is prioritised: Google's SRE book describes labelling every request with a criticality (from CRITICAL_PLUS down to SHEDDABLE) so that batch and prefetch traffic is dropped before user-facing traffic. In the food-delivery platform, checkouts and courier location updates outrank recommendations, which outrank analytics events. And it is aware of time: a request that has already waited longer than its caller's timeout should be dropped rather than processed. Facebook's engineers have described two refinements: a controlled-delay queue that allows only a short wait when a queue has not emptied recently, and switching to last-in, first-out under load, so that the freshest requests, whose users are still waiting, are served first.
A further step is to adapt the limit itself. Fixed concurrency limits are either too low on a good day or too high on a bad one; adaptive concurrency limiters raise the permitted number of in-flight requests while latency stays low and cut it when latency rises, borrowing the idea from TCP congestion control. At the very front of a system, admission control can take the form of a virtual waiting room, as in the ticket on-sale above: users are held in a queue at the edge and admitted at the rate the core can serve, which is load shedding presented as fairness.
Rate limiting algorithms
Rate limiting restricts how much work a particular client, API key, tenant or IP address may submit in a period. It differs from load shedding in purpose: shedding protects the server from total overload whoever causes it, while rate limiting shares capacity fairly and protects against any single client, buggy or malicious. Both answer the same way, usually with HTTP 429. Four algorithms dominate.
Rate-limiting algorithms compared
| Algorithm | How it works | Bursts | State per key | Notes |
|---|---|---|---|---|
| Token bucket | Tokens are added at rate r up to capacity b; each request spends one | Allows bursts up to b, then r on average | Two numbers: tokens and last refill time | The most widely used; burst and sustained rate are tuned separately |
| Leaky bucket | Requests enter a bucket that empties at a constant rate; when it is full, new requests are rejected | Output is perfectly smooth; bursts wait or are refused | A queue or a counter and timestamp | Used for traffic shaping; NGINX's request limiting is based on it |
| Fixed window counter | Count requests per calendar window (per minute); reset at the boundary | Up to twice the limit across a boundary | One counter | Simplest; 100 requests at 12:00:59 and 100 at 12:01:00 both pass |
| Sliding window log | Keep the timestamp of every request in the last window; count them | Exact | One entry per request | Precise but memory-hungry at high rates |
| Sliding window counter | Weight the previous window's count by how much of it still overlaps, and add the current count | Close approximation | Two counters | 30 s into a minute with 80 last minute and 40 now: 80 × 0.5 + 40 = 80 |
The token bucket deserves a closer look because it is the usual default. The bucket holds up to b tokens and gains r tokens per second. Each request takes a token; if none is left, it is rejected (or, in the shaping variant, delayed). A client that has been quiet can therefore send a burst of b requests at once, which suits real clients such as a mobile app opening a screen, while over any longer period it cannot exceed r on average. The leaky bucket is the mirror image: it smooths output to a constant rate, which is useful for protecting a downstream system that cannot accept bursts at all.
The token bucket
Worked example
Worked example: a token bucket for a partner API
A restaurant-partner API key is limited by a token bucket with capacity b = 20 and refill rate r = 5 tokens per second (300 per minute on average). The bucket is full at t = 0.
| t = 0: a burst of 30 requests | The first 20 take the 20 tokens and are forwarded; 10 are rejected with 429 |
|---|---|
| t = 0 to 1 s | 5 new tokens arrive (5 per second) |
| t = 1 to 10 s: a steady 8 requests per second | About 5 per second are accepted and 3 rejected; the bucket hovers near empty |
| t = 10 to 14 s: no requests | The bucket refills at 5 per second and is full again (20) at t = 14 |
| Upper bound over any period T | At most b + r × T requests: in any 60 seconds, 20 + 5 × 60 = 320, so the long-run average can never exceed 5 per second |
| Retry-After for a rejected request | The next token arrives in 1 ÷ r = 0.2 s, but the header counts whole seconds, so the response says Retry-After: 1 |
Capacity sets the burst a well-behaved client may send after a quiet period; the refill rate sets the sustained rate. Choose them separately.
A client that honours Retry-After, with a little jitter, settles at the refill rate; one that retries immediately just collects more 429s.
import math, time
class TokenBucket:
def __init__(self, rate, capacity):
self.rate = rate # tokens added per second
self.capacity = capacity # largest burst allowed
self.tokens = capacity # start full
self.updated = time.monotonic()
def allow(self, cost=1):
now = time.monotonic()
elapsed = now - self.updated
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
self.updated = now
if self.tokens >= cost:
self.tokens -= cost
return True, 0
wait = (cost - self.tokens) / self.rate # seconds until enough tokens
return False, max(1, math.ceil(wait)) # for the Retry-After header
bucket = TokenBucket(rate=5, capacity=20) # one bucket per API key
allowed, retry_after = bucket.allow()HTTP 429, Retry-After and where to enforce limits
HTTP has standard signals for both situations. 429 Too Many Requests, defined in RFC 6585 (2012), tells a client that it has sent too many requests in a given time. 503 Service Unavailable says the server as a whole is temporarily overloaded or in maintenance. Either may carry a Retry-After header (RFC 9110), giving a number of seconds or a date after which retrying makes sense. Many APIs also return headers announcing the limit, the remaining allowance and the reset time, traditionally X-RateLimit-*; an IETF draft is standardising RateLimit and RateLimit-Policy fields. Well-behaved clients honour Retry-After, add jitter so that rejected clients do not all return at the same instant, and treat repeated 429s as a reason to slow down rather than to retry harder (Section 9.4).
Limits can be enforced at several layers, and mature systems use more than one: at the edge or API gateway per IP address and API key, to stop abuse and runaway clients before they cost anything; per tenant, so that one large customer cannot starve the others; and inside each service, to protect its own capacity and its dependencies. A global limit across many gateway instances needs shared state, usually atomic counters in Redis, which adds a network call per request; a common compromise is to give each of N instances a local limit of about 1 ÷ N of the total and accept some imprecision. Limits should be set from measured capacity and published, so that partners can design to them, and the number of rejections should be monitored, because a sudden rise is either an attack, a client bug or a limit that no longer matches reality.
Dead-letter queues
Some messages fail every time: a malformed payload, an order for a restaurant that no longer exists, a bug triggered by one particular input. Retried indefinitely, such a poison message blocks a partition or wastes a worker forever. A dead-letter queue (introduced in Section 6.4) receives messages that have failed a set number of times (SQS calls this the maximum receive count in a redrive policy), so that the rest of the work can continue.
A dead-letter queue is a tool for defects, not for overload: if messages land there because consumers are too slow, the fix is capacity or back pressure, not a second queue. Every message in a DLQ means something the business expected did not happen, so the DLQ needs an owner, an alert on its depth or age, tools to inspect messages with their error and attempt count, and a way to replay them once the cause is fixed. Decide in advance what replaying late means for ordering and for messages whose business moment has passed: a refund can be replayed a day late, a courier assignment cannot.
Check your understanding
Workers process 200 messages per second and messages may wait at most 30 seconds. What is the largest acceptable queue length?
By Little's law, 200 × 30 = 6,000 messages; a longer queue means some messages will wait more than 30 seconds.
Why is the age of the oldest message a better alarm than queue length?
Age measures the delay users experience and rises immediately if consumers stall, whereas a length that looks normal can hide a stuck queue or a slow drain.
A token bucket has capacity 50 and refills at 10 per second. How many requests can a client make in the first second after a long quiet period?
About 60: the 50 tokens already in the bucket plus the 10 added during that second.
What is the difference between load shedding and rate limiting?
Load shedding protects the server from total overload whoever causes it; rate limiting caps what each client may send so capacity is shared fairly.
Sources and further reading
Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.
Ask: “Our ticket on-sale queue grew to 200,000 orders and customers waited 40 minutes for a result. How should we have bounded the queue and what should happen to the extra orders?”