Backpressure Strategies in Distributed Systems
Learn how backpressure prevents overload in distributed systems. Covers load shedding, rate limiting, buffering, and flow control patterns with examples.
What you'll learn
- ✓What backpressure is and why systems need it
- ✓Compare buffering, dropping, rate limiting, and flow control strategies
- ✓Apply backpressure patterns in message queues, APIs, and streaming pipelines
- ✓Avoid cascading failures caused by unbounded queues and producer-consumer imbalance
Prerequisites
- •Basic distributed systems concepts
- •Familiarity with message queues and producer-consumer patterns
When a producer generates data faster than a consumer can process it, something has to give. Without backpressure, the system buffers data indefinitely until memory is exhausted, then crashes. With backpressure, the system signals the producer to slow down, drops excess load gracefully, or takes other deliberate action. Every production distributed system needs a backpressure strategy, whether it is explicit or accidental.
The Overload Problem
Producer: 10,000 events/sec
Consumer: 3,000 events/sec
Gap: 7,000 events/sec accumulating
Without backpressure:
t=0: buffer = 0
t=1s: buffer = 7,000
t=10s: buffer = 70,000
t=100s: buffer = 700,000
t=300s: OOM crash
With backpressure:
Producer slows to 3,000 events/sec
OR excess events are dropped/rejected
OR buffer is bounded and producer blocks when full
The buffer is the silent killer. An unbounded queue (in-memory list, unbounded channel, or unlimited message queue) hides the overload until the system runs out of memory.
Strategy 1: Bounded Buffers (Block the Producer)
The simplest backpressure mechanism: make the buffer finite and block the producer when it is full.
import queue
# Bounded queue: producer blocks when full
buffer = queue.Queue(maxsize=1000)
def producer():
while True:
event = generate_event()
buffer.put(event) # blocks when queue is full
# Producer naturally slows down
def consumer():
while True:
event = buffer.get() # blocks when queue is empty
process(event)
This works well within a single process but does not translate directly to distributed systems where the producer and consumer are separate services communicating over a network.
In distributed contexts, bounded buffers appear as:
- Kafka: consumers with
max.poll.recordsand consumer group lag. Producers block when the in-flight request buffer is full. - RabbitMQ: queue length limits with
x-max-length. When exceeded, the queue drops or rejects messages. - gRPC: flow control windows limit how much data the sender can transmit before receiving acknowledgment.
Strategy 2: Drop (Load Shedding)
When the system is overloaded, drop excess requests rather than queuing them. This is better than crashing under load.
Load shedding decision flow:
Incoming request
│
┌────┴────┐
│ Current │── under threshold ──> Process normally
│ load? │
└────┬────┘
│
over threshold
│
┌────┴────┐
│ Priority │── high priority ──> Process
│ check │
└────┬────┘
│
low priority
│
Return 503 Service Unavailable
Prioritized load shedding
Not all requests are equal. A payment confirmation is more important than a recommendation refresh. Assign priorities and shed low-priority traffic first:
from enum import IntEnum
class Priority(IntEnum):
CRITICAL = 1 # payments, auth
HIGH = 2 # user-facing reads
MEDIUM = 3 # background syncs
LOW = 4 # analytics, prefetch
class LoadShedder:
def __init__(self, max_concurrent=100):
self.max_concurrent = max_concurrent
self.current_load = 0
self.shed_threshold = {
Priority.LOW: 0.7, # start shedding at 70% load
Priority.MEDIUM: 0.85, # start shedding at 85%
Priority.HIGH: 0.95, # start shedding at 95%
Priority.CRITICAL: 1.0, # never shed
}
def should_accept(self, priority: Priority) -> bool:
load_ratio = self.current_load / self.max_concurrent
return load_ratio < self.shed_threshold[priority]
Strategy 3: Rate Limiting at the Source
Instead of letting producers overwhelm consumers, limit the production rate. This pushes backpressure upstream to the original caller.
Client -> API Gateway (rate limit: 100 req/s per client)
│
v
Service A -> Message Queue -> Service B (consumer)
│
(if queue depth > threshold, reduce rate limit)
Adaptive rate limiting adjusts limits based on downstream health:
class AdaptiveRateLimiter:
def __init__(self, initial_rate=1000):
self.rate = initial_rate
self.max_rate = initial_rate
def on_success(self):
# Slowly increase rate (additive increase)
self.rate = min(self.rate + 10, self.max_rate)
def on_downstream_pressure(self):
# Quickly decrease rate (multiplicative decrease)
self.rate = max(self.rate * 0.5, 100)
def on_queue_depth_warning(self, depth, threshold):
if depth > threshold:
reduction = min(depth / threshold, 4) # cap at 4x reduction
self.rate = max(self.rate / reduction, 100)
This is analogous to TCP congestion control (AIMD: additive increase, multiplicative decrease).
Strategy 4: Flow Control (Reactive Streams)
Reactive Streams (and protocols like gRPC flow control) use explicit demand signaling. The consumer tells the producer how many items it can accept.
Consumer -> "I can handle 100 items" -> Producer
Producer sends 100 items
Consumer processes them
Consumer -> "I can handle 50 more" -> Producer
Producer sends 50 items
In Java’s Reactive Streams:
public class SlowSubscriber implements Subscriber<Event> {
private Subscription subscription;
@Override
public void onSubscribe(Subscription s) {
this.subscription = s;
s.request(10); // initial demand: 10 items
}
@Override
public void onNext(Event event) {
process(event);
subscription.request(1); // request one more after processing
}
}
The producer never sends more than the consumer has requested. This eliminates buffering entirely at the protocol level.
Strategy 5: Sampling
When the volume of data exceeds processing capacity and approximate results are acceptable, sample a fraction of the traffic:
Monitoring pipeline:
All servers emit metrics: 500,000 events/sec
Aggregator can handle: 50,000 events/sec
Solution: sample 10% of events
Each server: emit event with 10% probability
Aggregator: multiply counts by 10 for estimates
This is common in distributed tracing (Jaeger, Zipkin) and metrics collection. You lose precision but maintain system stability.
Backpressure in Message Queue Architectures
Kafka
Kafka handles backpressure through consumer lag. If consumers fall behind, the messages remain on the broker (disk-backed). Producers are not blocked unless broker disk is full. This is “implicit backpressure”: the lag is visible as a metric, but the producer is unaware.
Producer -> Kafka Broker (disk-backed) -> Consumer
Healthy: consumer lag = 0
Moderate: consumer lag = 10,000 (consumer is behind)
Critical: consumer lag = 1,000,000 (scale consumers or drop)
Monitor consumer lag and set alerts. Scale consumer instances horizontally when lag grows.
SQS
AWS SQS provides unbounded queuing (effectively infinite retention for 14 days). Backpressure is managed by monitoring queue depth and scaling consumers with auto-scaling policies.
SQS queue depth > 5,000 -> scale consumers from 3 to 10 instances
SQS queue depth > 50,000 -> alert on-call engineer
SQS queue depth > 500,000 -> trigger load shedding at producer
Backpressure in HTTP APIs
For synchronous HTTP APIs, backpressure manifests as increased latency and connection pool exhaustion. Strategies:
- Connection limits: limit the number of concurrent connections the server accepts.
- Request timeouts: reject requests that have been waiting too long in the queue.
- HTTP 429 Too Many Requests: explicitly tell clients to slow down with a
Retry-Afterheader. - HTTP 503 Service Unavailable: signal overload so load balancers route traffic elsewhere.
from fastapi import FastAPI, HTTPException
import asyncio
app = FastAPI()
semaphore = asyncio.Semaphore(100) # max 100 concurrent requests
@app.get("/data")
async def get_data():
if semaphore.locked():
raise HTTPException(status_code=503, detail="Server overloaded")
async with semaphore:
return await fetch_data()
Choosing the Right Strategy
| Scenario | Strategy |
|---|---|
| Single-process pipeline | Bounded buffer (blocking queue) |
| API gateway / edge | Rate limiting + 429 responses |
| Message queue consumer lag | Auto-scale consumers + monitor lag |
| Streaming pipeline | Reactive Streams / flow control |
| Monitoring / analytics | Sampling |
| Mixed-priority traffic | Prioritized load shedding |
Key Takeaways
Every distributed system has a producer-consumer imbalance somewhere. Backpressure is the deliberate mechanism that prevents that imbalance from cascading into failure. The worst approach is an unbounded buffer that hides the problem until memory is exhausted. Prefer explicit strategies: bounded buffers, load shedding with priorities, adaptive rate limiting, or reactive flow control. Monitor queue depths and consumer lag as leading indicators of overload, and design your system to degrade gracefully rather than crash under pressure.
Related articles
- System Design Circuit Breaker Pattern for Resilient Microservices
Learn the circuit breaker pattern to prevent cascading failures in distributed systems. Covers states, configuration, and implementation with code examples.
- System Design Consistent Hashing Deep Dive: Virtual Nodes and Rebalancing
Go beyond basic consistent hashing. Learn how virtual nodes solve imbalance, how rebalancing works during scale events, and real-world usage in Cassandra and DynamoDB.
- System Design Event Sourcing Explained: Architecture and Trade-offs
Understand event sourcing fundamentals, how it differs from CRUD, when to use it, and the real-world trade-offs you need to consider before adopting it.
- System Design Idempotency Patterns: Keys, Deduplication, and Exactly-Once Semantics
Learn how to design idempotent APIs and achieve exactly-once semantics using idempotency keys, deduplication stores, and transactional outbox patterns.