Backpressure Implementation
Backpressure is the mechanism by which a slow consumer tells a fast producer to ease off. Without it, the system has an implicit unbounded buffer somewhere, and that buffer eventually fills memory. With it, overload becomes a controlled slowdown or an explicit rejection.
How it works
Every hop in a pipeline needs a bounded amount of in-flight work and a way to signal it.
- Bounded queues: a fixed-capacity queue between stages. When full, a producer's put either blocks, fails, or drops.
- Credit-based flow control: the consumer grants the producer a number of credits; the producer sends only while it holds credits. HTTP/2, gRPC, and reactive streams work this way.
- Pull-based consumption: the consumer requests the next batch when ready, as with Kafka consumers polling at their own pace.
- Rejection at the edge: if the entire pipeline is saturated, the ingress returns 429 or 503 so clients slow down or retry later.
Choose the full-queue policy deliberately: block when data must not be lost and producers can wait; drop newest or oldest when freshness matters more than completeness; reject when a caller can handle and retry.
Implementation steps
- Draw the pipeline and find every buffer, including thread pool queues, socket buffers, and library defaults.
- Replace unbounded buffers with bounded ones sized from rate times acceptable delay.
- Pick the full-queue policy for each stage and document it.
- Use pull or credit protocols where available instead of push without feedback.
- Connect the final stage's limit to the edge, so saturation produces a client-visible signal.
- Add timeouts to blocking puts to avoid deadlock, and ensure no cycle of stages can wait on each other.
- Test by throttling the slowest consumer and observing memory, queue depth, and producer behavior.
Common pitfalls
- Unbounded executor queues in default thread pool configurations.
- Blocking an event loop thread while waiting for capacity.
- Applying backpressure in one service but buffering without limit in the next.
- Dropping silently with no counters, hiding data loss.
- Buffers sized for the worst burst, which only hides the problem and adds latency.
Metrics to watch
- Queue depth and age of the oldest item at each stage.
- Producer blocked time and rejected or dropped counts.
- Consumer lag in offsets or seconds.
- Memory use and garbage collection pressure.
- End-to-end latency percentiles during bursts.
Example code
import queue, threading
buf = queue.Queue(maxsize=1000)
def produce(item):
try:
buf.put(item, timeout=0.05)
return True
except queue.Full:
metrics.incr("dropped_full")
return False
def consume():
while True:
item = buf.get()
process(item)
buf.task_done()
Key takeaways
- Every buffer must be bounded and its full-policy chosen explicitly.
- Prefer pull or credit-based protocols over unconstrained push.
- Carry the signal all the way to the edge.
- Count every drop and rejection.
- Test by slowing the consumer and watching where memory goes.