See It Flow

Core Concepts

Backpressure

Backpressure protects systems from drowning in their own workload.

Mangesh MandlikLast updated:

What Is Backpressure?

A feedback signal from the bottleneck to whoever feeds it

Backpressure is a feedback mechanism that stops a producer from sending work faster than a consumer can finish it. When the consumer slows down or the queue in front of it nears its limit, the system tells upstream components to pause, slow down, or send less. It is about controlling the rate of work entering a bottleneck, not about storing the excess somewhere.

The arithmetic is the whole motivation. Suppose a service receives 2,000 events per second and its workers sustain 1,200. The backlog grows by 800 per second. After one minute that is about 48,000 events, assuming constant rates and no drops. At 4 KiB each, payload storage alone is 48,000 × 4 KiB = 187.5 MiB, before object headers, indexes, replication, or runtime bookkeeping. A bigger queue only moves the day the process falls over; it does not change the 800-per-second imbalance.

The mental model is a pipe feeding a narrow valve. Pressure builds behind the valve, and a well-designed system lets that pressure travel back up the pipe so the source eases off, instead of letting the pipe balloon. The question every design must answer is: what happens when input exceeds sustainable capacity for longer than the buffer can absorb? The honest options are to slow the producers, queue within a hard bound, reject work, drop low-value work, or add capacity. Each has different consequences for correctness and latency, and most real systems combine several.

Backpressure is a feedback loop, not a queueA producer sends 2,000 events per second into a bounded queue of 10,000 slots. A consumer drains the queue at 1,200 per second, so the queue gains 800 per second. A flow controller watches queue depth and, at the high-water mark, sends a dashed signal back to the producer (a pause, a reduced credit grant, or a rejection). Without that return arrow the queue only delays the failure. Rates are illustrative.Producer2,000 events/s offeredBounded queue10,000 slots, +800/sConsumer1,200 events/s sustainableFlow controllerdepth vs high / low markworkworkdepthslow down
Backpressure is a feedback loop, not a queue. A producer sends 2,000 events per second into a bounded queue of 10,000 slots. A consumer drains the queue at 1,200 per second, so the queue gains 800 per second. A flow controller watches queue depth and, at the high-water mark, sends a dashed signal back to the producer (a pause, a reduced credit grant, or a rejection). Without that return arrow the queue only delays the failure. Rates are illustrative.

Visual Story

Balanced → spike → queue explodes → backpressure signal → controlled recovery → equilibrium

Back Pressure

Back Pressure
Healthy Flow
0 / 0
🐢Up nextConsumer Slows

⤢ Expand for synchronized narration timeline

Why Buffering Is Not Backpressure

A queue absorbs a mismatch; only a feedback loop resolves it

A buffer absorbs a temporary mismatch. If a consumer stalls for 100 ms, a bounded queue lets the producer carry on without anyone noticing. If production stays above consumption, though, every buffer fills eventually, whatever its size. Backpressure begins at the moment fullness changes what the upstream side does.

Try the figure below. With the defaults (2,000 arriving, 1,200 served, 30,000 slots) the queue is full after 30,000 ÷ 800 = 37.5 seconds. Drag the capacity to its maximum and the failure moves out to 125 seconds. Drag the service rate to match arrivals and the line goes flat. Capacity changes when; only the rate difference changes whether.

2,000/s
1,200/s
30,000 items
Queue depth over two minutes at a constant rate mismatchQueue depth over 120 seconds for an arrival rate of 2,000 per second, a service rate of 1,200 per second and a capacity of 30,000 items, starting empty. The queue grows by 800 per second and is full after 37.5 seconds; after that, new work must be slowed, rejected or dropped. Constant rates, no drops, illustrative values.015,00030,0000306090120secondsqueue depthcapacity
Queue depth over two minutes at a constant rate mismatch. Queue depth over 120 seconds for an arrival rate of 2,000 per second, a service rate of 1,200 per second and a capacity of 30,000 items, starting empty. The queue grows by 800 per second and is full after 37.5 seconds; after that, new work must be slowed, rejected or dropped. Constant rates, no drops, illustrative values.
Backlog growth
+800/s
arrivals exceed service
Time to full
37.5 s
from empty
Wait when full
25.0 s
capacity ÷ service rate
Payload memory
117.2 MiB
at 4 KiB per item

Unbounded queues turn overload into a different outage

An unbounded queue converts a rate problem into memory exhaustion, long tail latency, and stale work. The memory failure is the loud one. The quiet one arrives first: a FIFO queue that holds N items in front of a consumer serving μ items per second makes the newest item wait about N ÷ μ seconds. At 1,200 per second, a 48,000-item backlog means every new request waits 40 seconds, long after most callers have timed out and retried. The consumer then spends its capacity on work nobody is waiting for.

A bounded queue makes overload visible early and makes the policy explicit: when the queue is full, something specific happens. Choosing the bound by available RAM is the wrong starting point. Start from the wait you can tolerate.

Sizing the bound with Little's Law

Little's Law relates the average number of items in a stable system to the average arrival rate (which equals throughput in steady state) and the average time each item spends in the system:

Little's Law and the queue bound it implies
L = λ × W

L = average items in the system     λ = average throughput     W = average time in system

example:   λ = 500 requests/s, W = 0.2 s      ->  L = 100 requests in flight on average

queue sizing (FIFO queue drained at service rate μ):
   max wait  W_max  ≈  N / μ          ->   N  ≈  μ × W_max
   μ = 1,200/s, W_max = 5 s           ->   N  ≈  6,000 items
   memory at 4 KiB/item               ->   6,000 × 4 KiB ≈ 23.4 MiB

The law holds for stable systems and says nothing useful about a queue that is growing without bound, so do not use it to predict where an overloaded queue "settles". It does give the practical rule: the queue bound is a latency budget in disguise. A 6,000-slot queue in front of a 1,200-per-second consumer promises at most about five seconds of queueing. Check the memory figure afterwards, as a constraint, not as the target. The bound should also be smaller than the callers' own timeout, otherwise admitted work is already worthless by the time it is served.

Backpressure Mechanisms

Pull demand, blocking, credits, rejection, and deliberate loss

Every mechanism answers one question: how does the consumer's limit reach whoever decides how much work exists? They differ in who holds the state, how fast the signal travels, and what happens to the work that does not fit.

Pull-based demand

The consumer asks for only as much as it can handle. In Reactive Streams-style protocols the subscriber calls request(n), and a compliant publisher must not signal more onNext items than the outstanding demand. The in-flight work is bounded by what the subscriber has asked for, so memory is bounded by design. Kafka consumers and many job queues are pull-based in the same spirit: nothing is delivered until the consumer fetches it.

Pull-based demand with request(n)A subscriber asks a publisher for four items with request(4). The publisher emits items 1 to 4 and then stops, because its outstanding demand is zero. The subscriber processes the four items at its own pace, then asks for four more. The publisher emits items 5 to 8 and again waits. The publisher never emits item 9 without a new request, so the amount of in-flight work is bounded by what the subscriber asked for. This bound holds only for as long as every hop in the chain propagates demand.Subscriberslow consumerPublisherobeys demand1. request(4)2. onNext items 1-43. outstanding demand = 0, wait4. process 1-4 at its own pace5. request(4)6. onNext items 5-8
Pull-based demand with request(n). A subscriber asks a publisher for four items with request(4). The publisher emits items 1 to 4 and then stops, because its outstanding demand is zero. The subscriber processes the four items at its own pace, then asks for four more. The publisher emits items 5 to 8 and again waits. The publisher never emits item 9 without a new request, so the amount of in-flight work is bounded by what the subscriber asked for. This bound holds only for as long as every hop in the chain propagates demand.

Two cautions. First, demand only controls delivery through that protocol. It does not stop an unrelated external source unless the signal is propagated all the way upstream, hop by hop. A reactive pipeline fed by an HTTP endpoint that accepts requests as fast as they arrive has bounded the inside and left the edge open. Second, request(Long.MAX_VALUE) means unbounded demand and quietly turns the mechanism off; several operators also prefetch a small bounded batch (a default such as 128 or 256 is common), which is fine, but it is a buffer you should know about.

Blocking or pausing the producer

A producer waits when a bounded channel is full. Inside a process this is the simplest correct answer: a bounded Go channel, a Java ArrayBlockingQueue.put, or a bounded asyncio.Queue. Streams expose the same idea as a return value: in Node.js, writable.write() returns false once the internal buffer passes highWaterMark, and the producer is expected to wait for the drain event. The trap is the blocked thread. Thread-per-producer designs can be exhausted by the very overload they are meant to survive; suspending asynchronously (as in the example below) holds no thread while it waits.

Bounded queue: the producer suspends when the queue is full (Python asyncio)
import asyncio

queue: asyncio.Queue = asyncio.Queue(maxsize=6000)   # sized from a latency budget

async def producer(source):
    async for job in source:
        await queue.put(job)       # suspends while full; no thread is held

async def worker():
    while True:
        job = await queue.get()
        try:
            await handle(job)
        finally:
            queue.task_done()

Credit-based flow control

The downstream side advertises how many bytes or messages it can accept. The sender spends credits as it sends and waits for replenishment. TCP's receive window and HTTP/2 stream windows are credit systems measured in bytes; many messaging protocols grant credit in messages. Credits turn the buffer from something that happens to the system into something the receiver sizes on purpose. The unit and replenishment rules differ per protocol, so the details matter more than the name.

Explicit rejection

An API returns 429 Too Many Requests or 503 Service Unavailable, or a broker refuses a publish, because it cannot safely queue more. The status codes mean different things: 429 says this caller is exceeding what it is allowed to send, while 503 says the server is temporarily unable to handle the request, which is the right signal for saturation that is not the caller's fault. Both may carry Retry-After, either as seconds or as an HTTP date; it is advisory, so well-behaved clients honor it and add jitter so they do not all return at once. gRPC services typically use RESOURCE_EXHAUSTED or UNAVAILABLE for the same purpose. The RateLimit-* response headers are still an IETF draft rather than a finished standard, so treat them as a convention you control on both ends.

Rejection is admission control, and it creates backpressure only if callers respond by sending less or later. A client that retries immediately converts each rejection into more load: three immediate retries per failure can multiply offered traffic by up to four. See Retry & Backoff for budgets and jitter, and Load Shedding for choosing what to refuse.

Dropping, sampling, and coalescing

A telemetry pipeline can drop low-priority debug events when saturated; a trading ledger cannot drop a single entry. The right loss policy depends on the value of the data, whether the source can replay it, and what you have promised. Two less blunt variants exist: sampling keeps one in N items, and coalescing (also called conflation) keeps only the latest value per key, which suits position updates and gauges where an older value is obsolete the moment a newer one exists.

Video players show the pull idea at the user's edge: they request the next segment only when their playback buffer drops below a target, and adaptive-bitrate logic lowers quality when downloads cannot keep up. The buffer is bounded, demand is driven by the consumer, and quality is the degradation knob.

Three ways to resolve a sustained 2,000/s versus 1,200/s mismatchA producer offering 2,000 events per second meets a consumer that can sustain 1,200. Option one, slow the producer: the producer is paused or granted less credit, ingress falls to about 1,200 per second, nothing is lost, and the producer's own latency or buffering absorbs the difference. Option two, reject at the edge: about 1,200 per second are admitted and about 800 per second are refused visibly with 429 or 503, so callers must handle failure. Option three, drop or sample: all 2,000 are accepted but about 800 per second are discarded, which is only acceptable for low-value data. Rates are illustrative.Producer2,000/s offeredSlow the producerpause / credits / blockingReject at the edge429 / 503 + Retry-AfterDrop or samplelow-value data only≈1,200/s flow, no lossdelay moves upstream≈800/s refused, visiblecaller must handle it≈800/s discardeddata is gone
Three ways to resolve a sustained 2,000/s versus 1,200/s mismatch. A producer offering 2,000 events per second meets a consumer that can sustain 1,200. Option one, slow the producer: the producer is paused or granted less credit, ingress falls to about 1,200 per second, nothing is lost, and the producer's own latency or buffering absorbs the difference. Option two, reject at the edge: about 1,200 per second are admitted and about 800 per second are refused visibly with 429 or 503, so callers must handle failure. Option three, drop or sample: all 2,000 are accepted but about 800 per second are discarded, which is only acceptable for low-value data. Rates are illustrative.

The figure compares the three non-capacity outcomes for the same 2,000-versus-1,200 mismatch. Slowing the producer loses nothing but pushes latency upstream. Rejecting keeps admitted work healthy and puts failure handling on callers. Dropping keeps everything running and permanently loses data. Adding consumers is the fourth option and the only one that changes the 1,200; it costs money, takes time, and helps only if the consumers are the real bottleneck.

When the Buffer Is Full: Policies

What you choose here determines what you lose

A bounded buffer forces a decision at the moment it fills. There are four standard answers, and each is correct for some data and dangerous for other data.

PolicyBehavior when fullReasonable forDangerous for
Drop newest / rejectRefuse the incoming item; existing queue untouchedNon-critical events; callers that can retry laterLedger entries, audit logs, anything with no replay
Drop oldestEvict the head to make room for fresh dataSensor readings, gauges, latest-value stateState mutations and payment events, where order and completeness matter
Block the producerProducer waits until space freesDurability required, delay acceptable, producer under your controlLatency-bound paths, or producers that hold scarce threads
Spill to durable storageOverflow goes to disk or a logData must be preserved and the delay is tolerableSustained overload: a disk queue is still a queue, just a larger one

Spilling deserves skepticism. It is a legitimate way to survive a burst without loss, but it recreates the unbounded-queue problem on a bigger medium: the backlog is safe and the wait is hours. Pair it with an age limit or a clear plan for draining it.

Age matters as much as depth. A queue can drop items at dequeue time when they have waited longer than the caller is still willing to wait, instead of doing the work and discarding the result. Several brokers have equivalents: RabbitMQ queues can be given a maximum length with an overflow behavior such as dropping the head or rejecting new publishes, and a per-message or per-queue TTL expires items by age.

TCP and HTTP/2 Flow Control Versus Application Backpressure

The transport protects its buffers, not your database

TCP is the original backpressure design. The receiver advertises a window in every acknowledgement; the sender may have no more unacknowledged data in flight than the smaller of that receive window and its own congestion window. When the receiving application stops reading, the socket buffer fills, the advertised window shrinks to zero, and the sender stops, probing periodically until the window reopens. The sender's own send buffer then fills, so a blocking write blocks or a non-blocking one reports that it cannot accept more. That is backpressure reaching application code, for free.

The limit is that TCP measures how fast the receiving process reads from the socket, not how fast it finishes the work. A typical read loop reads every message and puts it on an in-process queue. The socket buffer drains immediately, the window stays open, and TCP reports no pressure while the queue, and the database behind it, drown. Data can also accumulate in proxies, load balancers, and kernel buffers, all of which look like healthy transport.

TCP can be healthy while the application queue explodesLeft to right: a sender, TCP with the receiver's advertised window, the receiver's socket buffer, an application read loop, an unbounded in-process queue, and the workers that call a slow database. The read loop drains the socket buffer as fast as data arrives, so the advertised window stays open and TCP never tells the sender to slow down. The pressure is absorbed by the unbounded in-process queue, which grows until memory or latency fails. Only a bound at the queue, or a read loop that stops reading when the queue is full, turns the database's limit back into TCP pressure.Senderwrites fastTCP windowstays openSocket bufferdrained quicklyRead loopreads everythingUnbounded queuehidden, growingWorkersslow DB calls
TCP can be healthy while the application queue explodes. Left to right: a sender, TCP with the receiver's advertised window, the receiver's socket buffer, an application read loop, an unbounded in-process queue, and the workers that call a slow database. The read loop drains the socket buffer as fast as data arrives, so the advertised window stays open and TCP never tells the sender to slow down. The pressure is absorbed by the unbounded in-process queue, which grows until memory or latency fails. Only a bound at the queue, or a read loop that stops reading when the queue is full, turns the database's limit back into TCP pressure.

HTTP/2 and gRPC have the same shape. HTTP/2 flow control uses window credits per stream and per connection, replenished by WINDOW_UPDATE frames, with an initial window of 65,535 bytes in the specification. It prevents a fast sender from overrunning a receiver's buffers; it says nothing about the business processing rate. A gRPC server stream that reads messages as fast as they arrive and enqueues them in an unbounded application queue is overloaded in exactly the TCP example above. gRPC libraries expose the hook to do better: per-call manual flow control (request(n) in the Java API) and readiness callbacks such as isReady/onReady on the sending side.

The fix is to connect the application's true limit to the read side: bound the in-process queue, and when it is full, stop reading (or stop granting stream credit). From that moment the database's limit becomes a full socket buffer, a closed window, and a sender that waits. End-to-end backpressure is that chain of bounds; one unbounded link silently breaks it.

Kafka, Message Brokers, and Runtime Queues

Where the signal exists, and where it stops

Kafka: lag is a symptom, not a signal to producers

A Kafka consumer is pull-based: it fetches batches at its own pace, so the broker never overruns it. Consumer lag (log end offset minus committed offset) grows whenever consumption trails production. That lag does not slow producers. Producers write to the log independently of any consumer, and Kafka retains records by its time or size retention rules. If lag grows until the oldest unread records are deleted, the consumer's offset becomes invalid and its auto.offset.reset policy decides whether it jumps to the earliest or latest available record, and unread data is gone. Lag is evidence of an imbalance, not proof that backpressure is reaching anyone.

What a consumer can do is manage its own application pressure. consumer.pause(partitions) stops fetching from those partitions while poll() continues to be called, so the member stays in the group; stopping the polling would exceed max.poll.interval.ms and trigger a rebalance. max.poll.records bounds how much one poll hands to the application, and the consumer's internal fetch buffer is bounded by settings such as fetch.max.bytes. A rebalance can change partition assignment, so reapply pauses in the rebalance listener rather than assuming they persist.

Producer-side Kafka has its own, separate pressure: the producer's buffer.memory fills when the broker cannot keep up, and send() then blocks for up to max.block.ms before failing. Broker quotas can also throttle a producer's byte rate. Neither is driven by downstream consumer lag. Propagating consumer slowness back to a Kafka producer is something you build, typically by throttling at the system that feeds the producer.

Queues with leases and prefetch

In queue-based systems, prefetch limits, visibility timeouts, and maximum in-flight counts bound how much work is assigned to workers. In RabbitMQ with manual acknowledgements, basic.qos prefetch_count caps the unacknowledged messages a consumer holds; the broker withholds further deliveries until acks arrive. Too high a prefetch concentrates a backlog on a slow consumer while its peers sit idle; too low wastes capacity on round trips. Queue services with visibility timeouts hide a received message for a lease period, so an overloaded worker that cannot finish in time causes redelivery and duplicate work. Keep three things distinct: the queue's backlog, the number of messages currently leased to workers, and the application's own in-memory queue. Each needs its own bound and its own metric. See Queues & Workers for delivery semantics, and make consumers idempotent as described in Idempotency, because overloaded leases lead to duplicates.

Thread pools and executors

A bounded executor queue is a backpressure mechanism, and an unbounded one is one of the most common hidden queues. In Java, Executors.newFixedThreadPool uses an unbounded LinkedBlockingQueue, so a slow downstream grows the queue instead of pushing back. A ThreadPoolExecutor with a bounded queue and a rejection handler makes the choice explicit: the default abort policy throws, while CallerRunsPolicy makes the submitting thread run the task itself. That naturally slows the submitter, which is real backpressure, but it is dangerous when the submitter is an event-loop thread that must never block.

High-Water and Low-Water Marks

Why a controller needs two thresholds, and how to place them

A controller that pauses and resumes at the same threshold will oscillate. The moment it pauses, the queue dips below the threshold, so it resumes, and the queue rises over it again. The remedy is hysteresis: pause when depth reaches a high-water mark and resume only when it falls to a lower low-water mark. The gap converts rapid flapping into occasional, longer pauses. Pausing at 80% and resuming at 50% is a common illustration; those numbers are not universal.

Watermark controller (two-state machine)
HIGH = 0.80 * CAPACITY        LOW = 0.50 * CAPACITY
state = FLOWING

on every enqueue or dequeue:
    depth = queue.size()
    if state == FLOWING and depth >= HIGH:
        state = PAUSED;   signal_upstream(PAUSE)       # pause / withhold credit / reject
    elif state == PAUSED and depth <= LOW:
        state = FLOWING;  signal_upstream(RESUME)      # resume / grant credit
One threshold flaps; a high/low gap does notTwo traces of queue depth over 60 seconds from the same toy model: 1-second ticks, 160 items per second arriving while the producer is allowed to send, 100 per second served, and a paused producer sending nothing. With a single threshold at 800 the controller pauses and resumes on about two of every three ticks, 41 transitions in 60 seconds. With pause at 800 and resume at 500 it makes 13 transitions, each pause lasting several seconds. Shaded bands mark paused intervals. Illustrative model only.05001,000015304560queue depthhigh 800Single threshold: 41 pause/resume transitions in 60 s05001,000015304560queue depthhigh 800low 500High 800 / low 500: 13 pause/resume transitions in 60 sseconds
  • Queue depth
  • Producer paused
One threshold flaps; a high/low gap does not. Two traces of queue depth over 60 seconds from the same toy model: 1-second ticks, 160 items per second arriving while the producer is allowed to send, 100 per second served, and a paused producer sending nothing. With a single threshold at 800 the controller pauses and resumes on about two of every three ticks, 41 transitions in 60 seconds. With pause at 800 and resume at 500 it makes 13 transitions, each pause lasting several seconds. Shaded bands mark paused intervals. Illustrative model only.

The figure runs the same toy model twice, with 160 items per second arriving while flowing, 100 per second served, and a paused producer sending nothing. With a single threshold at 800 the controller flips state 41 times in 60 seconds. With 800/500 it flips 13 times, each pause lasting several seconds. The numbers belong to the model, but the shape is general.

Placing the marks

Derive both marks from the system rather than copying percentages. The high mark must leave room for the work that is still on its way after the signal is sent: HIGH ≤ CAPACITY − λ_max × T_react, where T_react covers signal propagation, in-flight requests, and producer reaction time. With a 10,000-slot queue, arrivals up to 600 per second, and a three-second reaction time, 1,800 more items can arrive after the signal, so the high mark should be at most 8,200. The low mark must not let consumers run dry while the producer wakes up: LOW ≥ μ × T_resume. At 350 per second and two seconds to resume, anything above about 700 items works, so 50% is generous. Lower marks mean longer pauses and less work waiting; higher marks mean shorter pauses and more queueing delay.

Queue occupancy is only one signal. CPU saturation, downstream latency, database connection exhaustion, and worker concurrency can become the binding limit well before the queue fills. A robust design watches the scarce resource itself (pool utilization, downstream p99, concurrency in use) and does not assume that queue depth captures system health.

A Realistic Overload Timeline

Short-spike absorption versus a flow-control loop that holds through a long slowdown

An image-transformation service receives 600 jobs per second and processes 600. At 12:01 a storage dependency slows and throughput falls to 350 per second. The backlog now grows by 250 per second. From an empty start, a 10,000-job queue fills in 10,000 ÷ 250 = 40 seconds. The service is configured to start reducing upstream credits at 70% (7,000 jobs) and reject non-critical jobs at 90% (9,000). At constant rates those marks are reached at 28 seconds and 36 seconds.

Overload timeline: honored signal versus ignored signalQueue depth in jobs over 120 seconds after a storage slowdown cuts service to 350 jobs per second while 600 per second keep arriving, so the backlog grows by 250 per second. If nothing changes, the 10,000-slot queue is full at 40 seconds and stays full while work is refused. If the service starts reducing credits at the 70 percent mark (7,000 jobs, reached at 28 seconds) and upstream drops its arrival rate to 300 per second, the queue drains at 50 per second and has fallen to 2,400 jobs by 120 seconds. The 90 percent mark of 9,000 is never reached. Illustrative numbers.02,5005,0007,50010,000020406080100120seconds after the slowdownqueue depth (jobs)70% - reduce credits90% - reject non-criticalfull at 40 s28 s
  • No control (signal ignored or absent)
  • Credits cut at 70%, arrivals fall to 300/s
Overload timeline: honored signal versus ignored signal. Queue depth in jobs over 120 seconds after a storage slowdown cuts service to 350 jobs per second while 600 per second keep arriving, so the backlog grows by 250 per second. If nothing changes, the 10,000-slot queue is full at 40 seconds and stays full while work is refused. If the service starts reducing credits at the 70 percent mark (7,000 jobs, reached at 28 seconds) and upstream drops its arrival rate to 300 per second, the queue drains at 50 per second and has fallen to 2,400 jobs by 120 seconds. The 90 percent mark of 9,000 is never reached. Illustrative numbers.

Whether the loop works depends entirely on the callers. Suppose they honor the signal and the arrival rate falls to 300 per second. The queue now drains at 350 − 300 = 50 jobs per second, so it falls from 7,000 to 2,400 by the 120-second mark and never reaches the 90% mark. Notice how slow that recovery is: the drain rate is the difference between service and arrivals, so a producer that only eases to 340 per second drains at 10 per second and the backlog lingers for minutes. The latency cost is real too. At 7,000 items and 350 per second the newest admitted job waits about 20 seconds, and at the full mark about 28.6.

If callers ignore the signal, or retry rejections immediately, the queue reaches 10,000 at 40 seconds and stays full. From there rejection alone does not prevent upstream overload, because the rejected work returns as retries on top of fresh arrivals. The scenario separates two things that are easy to conflate: the queue's ability to absorb a short spike, and the control loop required to survive a prolonged slowdown. The first is capacity planning; the second is backpressure.

Worked Example: Scaling a Kafka Consumer Group

Lag arithmetic, partition limits, and when throttling the source is the better answer

A topic receives 10,000 events per second. Each consumer instance sustains about 1,000 per second (an illustrative figure; measure yours). The group has two instances, so it sustains 2,000 and lag grows by 8,000 per second: 480,000 after a minute, 4.8 million after ten minutes, 28.8 million after an hour. Using FIFO arithmetic, the oldest unread event after ten minutes is 4.8M ÷ 2,000 = 2,400 seconds, 40 minutes, old.

A first fix is to scale out to eight instances. That gives 8,000 per second, which is still below the 10,000 arriving: lag still grows, at 2,000 per second. It is slower growth, not recovery, and a dashboard that only shows the slope flattening can hide that. The group needs more than 10,000 per second of capacity to shrink the backlog at all. With twelve instances (12,000 per second) the backlog drains at 2,000 per second, so 4.8 million events take 2,400 seconds, 40 minutes, to clear. Two constraints apply. The topic needs at least twelve partitions, because a partition is consumed by at most one member of a group at a time. And the consumers must not push the shared database over its own limit, in which case more consumers just make the real bottleneck worse.

StepProducedConsumedLag change
2 consumers10,000/s2,000/s+8,000/s
8 consumers10,000/s8,000/s+2,000/s (still growing)
12 consumers10,000/s12,000/s−2,000/s (drains 4.8M in 40 min)
2 consumers, source throttled to 2,000/s2,000/s2,000/s0/s (existing lag stays)

The last row is the true backpressure option, and it only exists if the producer is something you can slow: an ingestion API you can rate-limit, a batch job you can pause. Throttling stabilizes the system but does not clear the backlog already queued; that still needs spare consumer capacity or a decision to discard. If the producers are external and you cannot throttle them, the real choices are scaling, shedding, or accepting growing lag within your retention window.

Trade-offs and Failure Cases

Latency, deadlines, hidden queues, and signals that do not arrive

Backpressure propagates latency upstream. In a synchronous request chain, blocking consumes the caller's deadline and holds its threads and connections. Rejection makes overload explicit but requires every caller to handle failure. Dropping preserves system health at the price of lost data. Scaling workers helps when CPU is the bottleneck and can worsen a shared-database bottleneck. Pausing a consumer protects correctness while raising event age and threatening processing SLAs. Large buffers give better burst absorption and hide problems: the system looks healthy until the buffer is full, then fails abruptly. Small buffers expose trouble early and propagate signals sooner, at the cost of rejecting or pausing more often during ordinary bursts.

The most frequent failure is a hidden queue. An application advertises a bounded queue while another layer buffers without limit: an HTTP client that queues requests while its connection pool is exhausted, a thread pool with an unbounded work queue, a connection pool whose waiters are unbounded, a broker with unlimited retention. Stable behavior needs an end-to-end bound on admitted work, not a bound on one queue. A useful audit is to follow one request through the system and ask at each hop where it waits and who limits that wait.

Backpressure also interacts with other protections. Rate limiting caps what each caller may send regardless of downstream state; a circuit breaker stops calls to a dependency that is failing; load shedding chooses what to discard when saturated. Backpressure is the one that carries the downstream's capacity back to the source. Without bounded retries, any of the rejecting forms can be defeated by retry amplification.

How It Goes Wrong in Production

Six recurring incidents and what each one teaches

The unbounded in-memory queue. A service puts incoming messages on a plain list while its consumer runs several times slower than the producer. Heap usage climbs for minutes, garbage collection pauses lengthen, and the process eventually dies with an out-of-memory error, losing every in-flight item. Giving the queue a capacity and defining the full-queue behavior would have turned a crash into a visible, bounded rejection.

Silent consumer lag. A Kafka consumer falls behind after a deploy that slowed its handler. No alert covers lag, so the backlog grows for hours, users see stale data, and catching up takes longer than the outage itself. Alert on the lag growth rate and on oldest-record age, not only on absolute lag, which is meaningless without knowing throughput.

Scaling only the producers. Traffic rises and the team adds producer-side instances, because that is where the load appears. Consumer throughput is unchanged, so lag grows faster. Scale the stage that is the bottleneck, and measure lag before touching the stage in front of it.

Buffer everything, process later. A large buffer hides every spike for weeks, then a longer slowdown arrives and the backlog represents hours of latency. Users experience a system-wide outage even though nothing crashed. The buffer was a latency budget nobody had set.

No queue visibility. Queue metrics were never exported, dashboards looked green, and the backlog was discovered when the process ran out of memory. Queue depth, age, and in/out rates belong on the primary health dashboard, not in an afterthought.

The slow dependency inside the consumer. Every consumer calls the same slow database or external API, so pipeline throughput collapses to that dependency's speed and the queue in front of it fills. Addressing the queue misses the cause. Use timeouts, pooled and bounded connections, a circuit breaker on the slow call, and bulkheads so one dependency cannot take all the workers.

What to Monitor

Signals that show overload before users do

Measure ingress and completion rates, queue depth and oldest-item age, consumer lag, active in-flight work, time spent blocked, rejected and dropped counts, retry amplification, and end-to-end latency. The ingress versus completion pair is the imbalance itself; every other metric is a consequence.

Prefer age to count. A depth of 1,000 jobs can represent a few milliseconds of work or several hours, depending on throughput; age is roughly depth ÷ service rate for a FIFO queue, and it is what users feel. Alert on its trend, because a queue that is growing and a queue that is large but stable need different responses. Track saturation of the actual scarce resource (CPU, pool usage, downstream latency) alongside the queue, and keep rejected-versus-retried counts so you can tell whether callers are backing off or piling on.

When to Use Backpressure, and What It Is Not

Where the loop pays for itself and where other patterns fit better

Backpressure is essential wherever a producer and consumer run at independent rates and work accumulates between them: streaming pipelines, message queues and Kafka consumers, reactive code (RxJava, Reactor, Akka Streams build it in as a first-class primitive), and asynchronous request queues in high-throughput APIs, which need size limits and a rejection path. In a plain synchronous request-response call chain there is usually no explicit queue to signal through; there the equivalent protections are timeouts, bounded connection and thread pools, concurrency limits, and circuit breakers, which bound work in a similar way.

PatternProtectsHow it differs from backpressure
Rate limitingA service from any one callerStatic per-key cap, independent of how the consumer is doing right now
Load sheddingA saturated serviceDiscards work locally by priority; no required signal back to the source
Circuit breakerCallers from a failing dependencyStops calling an unhealthy downstream; reacts to failure, not to rate mismatch
BulkheadWorkloads from each otherPartitions resources; limits blast radius, not the total arrival rate

These compose. Rate limits cap individual callers at the edge, backpressure carries the consumer's limit through the internal pipeline, load shedding is the last-resort drop when the signal is absent or ignored, and bulkheads keep one workload's backlog from consuming everyone's workers. Backpressure is also not a guarantee of anything: it cannot create capacity, it cannot help if the signal never reaches the true source, and it trades throughput and latency for stability.

Key Takeaways

Rules for making overload visible and bounded

  • Buffering is not backpressure: an unbounded queue turns a rate mismatch into memory exhaustion and stale work. A 48,000-item backlog at 1,200 items/s means a 40-second wait for every new request.
  • Size the queue bound from the wait you can tolerate (N ≈ μ × W_max), keep it below callers' own timeouts, and check memory only as a constraint.
  • Decide the full-buffer policy per data type: reject or drop is fine for retryable or non-critical events and dangerous for ledger or audit data.
  • Use two thresholds (high and low water marks) so the controller does not flap around a single limit.
  • Hidden queues defeat the loop: Executors.newFixedThreadPool has an unbounded queue, and Kafka lag is a symptom rather than a signal to producers, so add an explicit signal or throttle the source.
  • Scale the bottleneck stage, alert on lag growth rate and oldest-item age, and combine backpressure with rate limits, load shedding and bulkheads rather than expecting it to create capacity.

Where to Go Next

Related lessons

Rejection is only half of the story if clients retry badly, so read Retry & Backoff next and then Rate Limiting for admission at the edge. For deciding what to discard when saturated, see Load Shedding; for containing a slow dependency, see Circuit Breaker and Bulkhead. The queues and leases behind most of the examples above are in Queues & Workers and Job Processing, and Idempotency covers making redelivered work safe. For streaming APIs under load, see gRPC.