The idea: the extra items must go somewhere
A producer makes 3 items per tick. A consumer can handle only 1. Every tick, 2 items are left over. They cannot vanish: they pile up in memory, they make the producer wait, they are thrown away, or they are never made. Backpressure is the choice among these: a way for the slow side to push back on the fast side.
The page runs one scenario on a tick clock. Pick a strategy and press Next Tick, or run a demo. The counters show items produced, consumed and dropped. The memory panel charges 10 MB per queued item against a 120 MB limit. The last counter is the latency of the item at the head of the queue: how many ticks it has waited.
Unbounded queues fail late
The easy fix is a queue with no limit. Every put() succeeds, so everything looks fine at first. But the queue grows by 2 items per tick, and so does memory. The head item waits longer each tick, so the results the consumer delivers get staler and staler. At tick 6 the queue holds 13 items, 130 MB, over the 120 MB limit. The kernel's OOM killer ends the process (exit code 137), and all 13 queued items are lost (Demo 1: unbounded queue, OOM-killed). An unbounded queue does not remove the problem. It only delays it, then turns it into a crash.
Bounded queue: block the producer
Give the queue a capacity, here 5. When it is full, put() blocks: the producer's thread stops, holding its item, until the consumer frees a slot. Java's ArrayBlockingQueue and Go's buffered channels work this way. The slowdown travels upstream by itself: the producer now makes 1 item per tick, the consumer's rate (Demo 2: 11 produced, 6 consumed, 0 dropped, memory never above 50 MB). The cost is a blocked thread, and a blocked thread can't do anything else, such as answer a health check or read from a socket.
Bounded queue: drop newest or drop oldest
Sometimes the producer can't wait: a sensor, a mouse, a market feed. Then a full queue must drop something. Drop newest throws away the arriving item and keeps the old ones. Drop oldest throws away the head and keeps the new one. Both drop 8 of 18 items in 6 ticks (Demos 3 and 4). What differs is which items survive.
| drop newest | drop oldest | |
|---|---|---|
| a full queue drops | the arriving item | the item at the head |
| head waited at t = 6 | 3 ticks | 1 tick |
| good for | work that must be done in order (jobs, log lines) | state where only the latest matters (prices, positions, UI events) |
| examples | Reactor onBackpressureDrop, a full TCP accept backlog | Reactor onBackpressureLatest, Akka Streams OverflowStrategy.dropHead |
Pull: Reactive Streams request(n)
Instead of pushing items and handling the overflow, let the consumer say how many it wants. In Reactive Streams the subscriber calls request(n) on its subscription, and the publisher may send at most n items with onNext. This demand flows upstream, the opposite way to the data. On this page the consumer calls request(5) when it subscribes (room for 5 items) and request(1) each time it finishes one.
Queued items plus open demand never exceed 5, so nothing is dropped and memory stays bounded. When demand is 0, the producer simply makes nothing. No thread is blocked, so the same signal works across async code and across a network (Demo 5: 10 produced, 6 consumed, 0 dropped). The numbers look like blocking because both settle at the consumer's rate; the difference is how the signal travels.
Bursts: what a bounded queue is for
A bounded queue is not only about overload. Real traffic comes in bursts with quiet time in between. A queue of 5 takes a burst of 6 items in 2 ticks without blocking or dropping anything, and the consumer drains it during the idle ticks after (Demo 6: 6 produced, 6 consumed, 0 dropped). So size the queue for the bursts you expect, not for sustained overload: if the producer is faster on average, any queue fills, and only blocking, dropping or demand helps.
In real systems
| system | backpressure |
|---|---|
| TCP | the receiver's window says how many bytes it can take: pull, at the byte level |
| Kafka | consumers pull with poll(), so a slow consumer just falls behind; the log is a bounded-by-retention buffer |
Java ThreadPoolExecutor | a bounded work queue, then a rejection policy (drop, or CallerRunsPolicy to slow the caller) |
| Project Reactor, RxJava, Akka Streams | request(n) demand, plus onBackpressureBuffer, Drop and Latest when a source can't be slowed |
| HTTP servers | a full accept queue or worker pool: refuse new connections, or answer 503 or 429 |
See also Bounded Buffer / Producer–Consumer for how a blocking queue is built from semaphores, Rate Limiting: Token Bucket vs Leaky Bucket for limiting a client's rate before the queue, How Kafka Moves a Message for a pull-based consumer, and How TCP Congestion Control Works for a sender that slows down to what the path can carry.
See also Push vs Pull Messaging: the same slow-consumer problem between a broker and its consumer, where a missing prefetch limit gets the consumer OOM-killed and a pull consumer just builds up lag.
What the page leaves out
Real time instead of ticks; consumers whose speed varies; batching demand (Reactor's limitRate asks again only when 75% of the prefetch is used); timeouts on a blocked put() (offer(item, timeout)); load shedding by priority; propagating backpressure through many stages, where one slow stage slows them all; and garbage-collector thrashing, which usually makes a growing queue slow long before the OOM kill.