Skip to content
Codeloom
System Design

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.

·7 min read · By Codeloom
Intermediate 10 min read

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.records and 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-After header.
  • 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

ScenarioStrategy
Single-process pipelineBounded buffer (blocking queue)
API gateway / edgeRate limiting + 429 responses
Message queue consumer lagAuto-scale consumers + monitor lag
Streaming pipelineReactive Streams / flow control
Monitoring / analyticsSampling
Mixed-priority trafficPrioritized 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.