What Is Backpressure in Distributed Systems?

Backpressure is the signal a slow consumer sends back to a fast producer telling it to slow down. The term is borrowed from fluid dynamics, where it describes resistance to flow in a pipe, and in software it describes the same thing applied to data: when work arrives faster than it can be processed, backpressure is the mechanism that pushes that resistance back to the source instead of letting the work pile up.

What Is Backpressure?

Every producer-consumer system has two rates: the rate at which work is created and the rate at which it is completed. When the first exceeds the second, the difference has to go somewhere. It goes into a buffer.

A buffer absorbs a temporary mismatch, which is exactly what it is for. Traffic is bursty, consumers pause for garbage collection, and downstream services slow down without warning. A buffer smooths all of that out. What a buffer cannot do is absorb a sustained mismatch. If the producer is permanently faster than the consumer, no buffer is large enough — it only changes how long the system takes to fail.

Backpressure is what turns a buffer from a shock absorber into a control system. Instead of accepting work indefinitely, the buffer has a limit, and reaching that limit produces a signal that travels upstream — in the opposite direction to the data. The producer learns that the consumer is behind and can do something about it. The amount of work a consumer has declared it can accept is its demand, and backpressure exists precisely where demand is communicated rather than assumed.

What Happens Without Backpressure

An unbounded buffer does not remove the problem; it relocates it. The queue grows, memory fills, and the garbage collector spends progressively more time recovering progressively less space. Latency climbs across every part of the process, not just the part that is behind, because pause times affect all threads. Eventually the process runs out of memory and dies.

The damaging part is where the failure surfaces. The service that crashes is the one holding the buffer, which is usually the consumer — but the consumer was never the problem. It was doing its job at the rate it was capable of. The fault lies with a producer that was never told to stop, and nothing in the crash points to it.

An unbounded queue converts a throughput problem into a memory problem, and does it silently. A bounded queue surfaces the same throughput problem at the source, immediately, as something the producer can act on. The second is far easier to operate, which is why bounding is the default recommendation even when the bound is generous.

Backpressure vs Rate Limiting vs Throttling vs Load Shedding

These four terms are often used interchangeably, but they mean different things. All four are ways a system reacts to more work than it can handle, but they differ in who decides, where the limit acts, what becomes of the work that does not get through, and whether any of them track what the system can really take.

BackpressureRate limitingThrottlingLoad shedding
Who decidesThe consumer, from its actual capacityAn operator, from a configured capAn operatorThe system, on crossing a threshold
Where it actsBetween stages, signalling upstreamAt the entry pointAt the entry pointInside the overloaded service
Excess work isNot sent; the producer is told and decidesRejected at the entry pointDelayed at the entry pointDropped inside the service
Tracks real capacityYes, continuouslyNo, the cap is fixedNoOnly at the threshold
Typical useProducer-consumer pipelinesPublic APIsBatch and background workOverload protection

A rate limiter applies a cap — 100 requests per minute — whether the system is idle or on fire. Backpressure has configured numbers too — a buffer size, a wait before giving up. But those numbers are boundaries rather than verdicts. What pushes the system across them is the running gap between production and consumption, and the consumer's side of that gap moves with hardware, data and whatever the downstream dependencies are doing this minute. The limiter enforces a number; backpressure reports a condition.

That makes them complements, not alternatives. A well-built system rate limits at its public edge, where clients are untrusted and a predictable published cap is a feature, and applies backpressure internally between its own stages, where the useful signal is real capacity instead of a guess. Load shedding sits underneath both as the last resort: when even backpressure has not been enough, something has to be dropped, and dropping deliberately by priority beats collapsing arbitrarily. Shedding, like throttling, can also be a producer's response to a backpressure signal rather than a mechanism in its own right. What triggers an action and what the action is are different questions.

How Backpressure Is Signalled

Four mechanisms account for most implementations.

  • Blocking. The buffer has a fixed capacity and the producer waits when it is full. A Java BlockingQueue works this way: put() does not return until space exists. This is the simplest form and needs no protocol, because the producer thread is physically stopped. That only works when producer and consumer share a process. Across a network a producer can still wait, but the wait becomes something it opts into rather than something the buffer imposes.
  • Demand-based, or pull. The consumer requests a specific quantity of work and the producer sends no more than that. Reactive Streams formalizes this as request(n). Kafka consumers approximate it by controlling their own poll rate — though a consumer that simply stops polling is evicted from its group, so the real mechanism is pause() and resume() rather than silence. Pull bounds what reaches the worker, which is why pull-based message queues are often described as backpressured. Pull only becomes backpressure by the definition above once the broker also bounds the backlog, because that is what gives the producer something to run into.
  • Rejection. The buffer refuses the item and says so: offer() returns false; an API returns 429. The producer is not blocked, so it keeps control and decides what to do about it. This suits distributed producers, which cannot be blocked from a distance.
  • Dropping. The buffer discards something to make room, choosing the oldest item, the newest, or everything except the latest. Strictly this is an overflow policy, not a signal — nothing travels upstream, and the producer is never told — but it is what the reactive libraries call backpressure, so it belongs in the list. It is the only mechanism that loses data, and it should be a deliberate choice, never a default. It is right for telemetry, where the current value matters and history does not, and wrong for anything transactional.

Backpressure in Reactive Streams and Project Reactor

Reactive Streams exists largely to standardize backpressure. A subscriber calls request(n) to declare how many elements it can handle, and the specification requires that the publisher never emit more than the outstanding demand. Nothing is sent until it has been asked for, so the consumer sets the pace by construction, not by convention.

Project Reactor and RxJava implement this specification and add operators for the case where a source cannot be slowed — clock ticks, sensor readings, an external feed that has never heard of your demand. In Reactor, onBackpressureBuffer holds the overflow, onBackpressureDrop discards it, onBackpressureLatest keeps only the most recent element, and onBackpressureError fails immediately rather than hiding the condition; RxJava offers the first three. Each is a different answer to the same question: what should happen to work that arrives when there is nowhere to put it.

The protocol has two limits worth knowing. The first is that a subscriber may request Long.MAX_VALUE, which publishers are allowed to treat as effectively unbounded — so a system can be built entirely on Reactive Streams and still have no backpressure, if every subscriber asks for everything. The second is that all of this happens within one process: request(n) is a method call on an object in local memory. Once a network sits between producer and consumer, the demand signal has to be carried by something else.

Backpressure in Distributed Queues

Distributed systems are where backpressure gets difficult, because the mechanism that makes the in-process case easy disappears. A local bounded queue applies backpressure by blocking the producer thread on its own initiative. When the producer runs on a different machine, the queue has no thread of yours to suspend. The buffer is no longer an object on the heap; it lives on a server that both sides reach across a network, so all it can do is answer and leave the waiting to the caller.

Three consequences follow. First, the bound has to live in the queue itself, because that is the only component that knows how full it is. Second, the producer must be told and must be capable of responding — a rejection nobody handles still stops the backlog, but leaves the producer no better informed than before. Third, queue depth becomes the health metric that matters. Depth is the earliest place a rate mismatch becomes visible.

There is also a failure mode with no in-process equivalent. A local consumer that stops is a thread you can inspect. A distributed consumer that stops may have crashed, may be partitioned away, or may simply be slow, and from the queue's point of view these look identical. This is why distributed queues pair capacity limits with visibility timeouts and delivery limits: the bound handles the fast producer, the visibility timeout returns work from a consumer that went quiet, and the delivery limit stops that work circulating forever.

Backpressure in Valkey and Redis

Valkey and Redis provide no backpressure of their own. A list has no configurable depth limit — the structural ceiling of 4,294,967,295 (2³² − 1) elements is unreachable in practice, because memory binds long before it. Four configurations govern what happens as memory fills, and not one of them tells a producer that this particular queue is too long:

  • maxmemory is unset by default, so there is no ceiling at all. The list grows until the operating system intervenes.
  • With maxmemory set and the default eviction policy of noeviction, nothing is deleted — writes are rejected with an OOM error instead. That is a signal of a kind, but a server-wide one: every write that adds data fails at once, so it reports that the server is full rather than that this queue is.
  • With an allkeys-* policy the server evicts from the whole keyspace — by approximate LRU, by frequency, or at random depending on the policy — so the queue holding your work is as eligible as anything else.
  • With a volatile-* policy only keys carrying a TTL are eligible, and a queue key normally has none — so the queue is never what gets evicted. The server frees space by discarding unrelated TTL-bearing keys instead, and only behaves like noeviction if no key on the instance has an expiry at all.

Two built-in mechanisms come closer than the rest. XADD takes a MAXLEN argument, and a list can be trimmed with LTRIM after every push — both cap length, but both do it by discarding, so the producer is never told and older work is silently lost. An atomic LLEN-then-LPUSH in a Lua script is the honest open-source answer if you want rejection instead of discarding; you are then maintaining that script yourself.

Redisson's RBlockingQueue carries the same gap into the client library: its blocking is entirely on the consumer side — useful for workers waiting on an empty queue, but it will never refuse a producer or make one wait. Backpressure has to be added deliberately. The open-source RBoundedBlockingQueue does offer a capacity, but Redisson deprecated it in 3.46 when Reliable Queue arrived, so it is not a foundation for new code. Reliable Queue does the same job with two pieces: maxSize is the ceiling on queued messages, and an optional timeout on the enqueue call decides what happens once that ceiling is reached.

RReliableQueue<Order> queue = redisson.getReliableQueue("orders");
queue.setConfig(QueueConfig.defaults().maxSize(10_000));

Message<Order> msg = queue.add(QueueAddArgs
    .messages(MessageArgs.payload(order))
    .timeout(Duration.ofSeconds(5)));

if (msg == null) {
    // Not enqueued: the queue stayed at maxSize for the whole wait.
}

The producer waits for space and, if none appears within the timeout, add() returns null. That is a bounded buffer and an upstream signal — the two things backpressure requires. A companion setting, maxMessageSize, bounds payload bytes as well, because a message count alone does not constrain memory: ten thousand small messages and ten thousand large ones are the same number and wildly different footprints. Note that Reliable Queue requires Redisson PRO; on the community edition the call to obtain one fails at runtime.

Two neighbouring tools are easy to confuse with this. A distributed semaphore bounds work in flight instead of work queued — the right instrument when what you are protecting is a fragile dependency rather than your own heap. Redis Streams with consumer groups let the consumer set its own pace: a consumer that stops reading simply stops receiving, but XADD never blocks and never reports that the stream is too long, so the producer is told nothing. That is consumer-driven flow control, not backpressure in the sense used here.

For the implementation in full — producer strategies, consumer bounds and queue-depth monitoring — see handling backpressure in Java with Valkey and Redis Reliable Queue.

Backpressure Best Practices

  • Bound every buffer. Even a bound set too high changes the failure from an unexplained crash into a specific, attributable error. Pick a number you can defend and adjust it later.
  • Choose the producer's response explicitly. The mechanisms above describe how the limit makes itself known; what the producer then does is a separate decision — block, fail fast, shed or divert. Each is defensible, and the default is rarely the one you want. Deciding by omission means the behaviour under load was never designed.
  • Monitor depth, not just throughput. A saturated system reports excellent throughput, precisely because nothing is idle — which is why throughput alone cannot distinguish healthy from doomed. Queue depth is the metric that moves early.
  • Do not let backpressure become silent dropping. If the producer's response to rejection is to discard work without recording it, the system has data loss with a rejection mechanism bolted on. Rejected work should be logged, counted, or diverted to an overflow store and replayed later. Note that this is not the same as a dead-letter queue, which holds work that failed processing rather than work that was never accepted.
  • Propagate to the edge. Backpressure that stops at the first internal stage has moved the queue, not fixed it. The signal should reach whatever admits work into the system, where it can become a 429, a slower poll, or a paused import.
  • Never retry immediately on rejection. A rejection is a request to send less, and answering it with an instant retry sends more. Back off before trying again.

Redisson provides distributed queues, semaphores and rate limiters through one Java API across Valkey and Redis, with the atomic logic already written and tested. Reliable Queue, with the bounded enqueue and producer signal described above, is part of Redisson PRO — see the feature comparison, or start a free trial.

Backpressure: Frequently Asked Questions

What Is Backpressure in Simple Terms?

Backpressure is a slow consumer telling a fast producer to slow down. When work arrives faster than it can be processed, a bounded buffer fills up, and reaching that limit sends a signal back to the source. Without it, the excess work accumulates in memory until the system runs out and crashes — usually in the component that was behaving correctly, not the one that was going too fast.

What Is the Difference Between Backpressure and Rate Limiting?

Rate limiting enforces a fixed cap that someone configured in advance, and it applies whether or not the system is under strain. Backpressure reflects the consumer's actual capacity in the moment, which changes constantly. Rate limiting is a policy; backpressure is a measurement. Most systems need both — rate limiting at the public edge, backpressure between internal stages.

Is Backpressure the Same as Throttling?

No. Throttling delays work to slow it down, and is normally configured at an entry point. Backpressure is the signal that the work should not have been sent yet, and it originates from the consumer's real capacity. Throttling can be one response to backpressure — a producer that has been told to slow down may implement that by throttling itself — but the signal and the response are separate things.

How Do You Handle Backpressure in Java?

Within one JVM, use a bounded BlockingQueue and let put() block, or use Reactive Streams with Project Reactor or RxJava where request(n) makes demand explicit. Across multiple machines neither works as given: the queue has no thread of yours to suspend, and request(n) is a local method call that does not cross a network. What you need instead is a queue with a configured capacity limit and an enqueue call that reports failure when that limit is reached. A remote producer can still wait — it declares how long it is willing to, and the failed return tells it the wait was not enough.

Does Redis Support Backpressure?

Not on its own. Valkey and Redis have no queue depth limit, and what happens at the memory ceiling depends on configuration: maxmemory is unset by default, so nothing bounds growth at all; once it is set, the default noeviction policy rejects every write that adds data rather than deleting anything, while an allkeys-* policy may evict the queue itself. None of those is a per-queue signal, so backpressure must come from the client library. Redisson's RBlockingQueue is unbounded and provides none, while its Reliable Queue supports a maxSize limit with a timeout on enqueue.

Similar terms