Be Software Architecture Wise
Be Wise Apps

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 captions
Listen 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

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

A spike of arrivals above consumer capacity builds a backlog in the queue, which the consumers then drain at their steady rate after the spiketimerateconsumer capacityarrivals spikebacklogbuildsbacklog drainsprocessing rate (solid) stays at capacityOrderAPIqueue (bounded)WorkerWorkerWorkercompetingconsumers
During the spike the queue absorbs arrivals above consumer capacity; afterwards the consumers drain the backlog at their steady rate.

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 workers40 ÷ 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 promiseL = λ × 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 allows25,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'
Decision75 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

A fast producer feeds a bounded queue that is full because the consumer is slower; the system must block the producer, reject new work or drop some workThe queue is full: choose deliberately what happens nextProducer500 per secondbounded queue: 8 of 8 slots usedConsumer300 per secondBlock the producerit waits for space:back pressure upstreamReject new workfail fast with 429 or 503;the client may retry laterDrop or replaceoldest, stale orlowest-priority items
When a bounded queue is full the system must block, reject or drop; choosing one deliberately is what keeps overload contained.

Choosing what happens when a queue is full

StrategyBehaviourSuitsRisk
Block the producerProducer waits until there is spacePipelines and batch jobs where the producer can pauseBlocked producers hold threads and connections; across services this spreads the slowdown upwards
Reject new workFail fast with an error such as 429 or 503Interactive requests; the client can retry, wait or show a messageClients that retry immediately turn rejection into more load (Section 9.4)
Drop oldestDiscard the item that has waited longestTelemetry, location updates, live prices: newest data is most valuableSilent loss; must be counted and visible
Drop by priorityDiscard the least important items firstMixed workloads: keep checkouts, drop analytics eventsLow priorities may starve; priorities must be assigned honestly
Spill to durable storageOverflow to disk or a durable queueWork that must eventually happen but can wait hoursJust 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

AlgorithmHow it worksBurstsState per keyNotes
Token bucketTokens are added at rate r up to capacity b; each request spends oneAllows bursts up to b, then r on averageTwo numbers: tokens and last refill timeThe most widely used; burst and sustained rate are tuned separately
Leaky bucketRequests enter a bucket that empties at a constant rate; when it is full, new requests are rejectedOutput is perfectly smooth; bursts wait or are refusedA queue or a counter and timestampUsed for traffic shaping; NGINX's request limiting is based on it
Fixed window counterCount requests per calendar window (per minute); reset at the boundaryUp to twice the limit across a boundaryOne counterSimplest; 100 requests at 12:00:59 and 100 at 12:01:00 both pass
Sliding window logKeep the timestamp of every request in the last window; count themExactOne entry per requestPrecise but memory-hungry at high rates
Sliding window counterWeight the previous window's count by how much of it still overlaps, and add the current countClose approximationTwo counters30 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

A token bucket refilled at 5 tokens per second and holding at most 20; each request takes a token and is forwarded, or is rejected with HTTP 429 when the bucket is emptybucket: at most 20 tokensrefill:5 tokens/stake 1 tokenRequestsper API keyToken left?yesnoForward to API429 Too ManyRequests
Each request must take a token; the refill rate sets the sustained rate and the bucket's capacity sets the largest burst.

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 requestsThe first 20 take the 20 tokens and are forwarded; 10 are rejected with 429
t = 0 to 1 s5 new tokens arrive (5 per second)
t = 1 to 10 s: a steady 8 requests per secondAbout 5 per second are accepted and 3 rejected; the bucket hovers near empty
t = 10 to 14 s: no requestsThe bucket refills at 5 per second and is full again (20) at t = 14
Upper bound over any period TAt 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 requestThe 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()
A token bucket with lazy refill: tokens are topped up for the time elapsed since the last call, so no timer is needed. In production the state lives in a shared store such as Redis, updated atomically.

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.

Next · 9.4Resilience patternsThe stability patterns that stop one slow dependency taking down a system: timeouts and deadlines, retries with backoff, jitter and budgets, circuit breakers, bulkheads, fallbacks, idempotency and hedged requests.Continue →

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?”