Why architecture matters here
The architectural reason backpressure matters is that queues hide problems until they can't. A queue between a fast producer and a slow consumer buys time, and time is only useful if the imbalance is temporary. If it is structural — the consumer is simply 20% slower — every queue in the path fills at a rate proportional to the imbalance, and the system's behavior is determined by whatever happens at the high-water mark. Unbounded queues make that behavior 'OOM at an unpredictable moment'; bounded queues without policy make it 'exception nobody handles'; bounded queues with policy make it a design decision you took deliberately in a code review rather than at 3am.
The second reason is that the signal must travel end to end or it travels nowhere. A chat server may respect gRPC readiness toward clients but read from Kafka as fast as the broker allows; the mismatch just moved inside the process, into a queue between consumer thread and connection writers. Real backpressure is a chain property: mobile client ← transport window ← connection writer ← fan-out queue ← Kafka fetch. Break one link — an unbounded fan-out buffer, a fire-and-forget send — and the chain protects nothing. This is why architecture reviews of streaming systems should trace one message's path and ask at every hop: what bounds this, and what happens when the bound is hit?
Third: one slow consumer must not become everyone's problem. In fan-out (pub/sub, live dashboards, market data), blocking the producer on the slowest subscriber time-travels the whole feed backward. The architecture must isolate per-subscriber queues and choose per-subscriber policy — drop, conflate, or evict — because 'fairness' between a hedge fund's colo client and a phone on hotel wifi is not sameness. And head-of-line nuance matters: HTTP/2 multiplexed streams share one TCP connection, so one stalled stream's unread data can jam the connection window and starve its siblings — a per-connection blast radius QUIC was partly designed to fix.
The architecture: every piece explained
Top row: the data path. The producer emits into a send buffer — the first bounded queue and the first decision: its size trades burst absorption against latency and memory. Below it, the transport window is the credit system built into the stack. TCP: the receiver advertises a window; unread data shrinks it to zero and the sender's kernel blocks. HTTP/2: each stream has a credit balance and the connection has one; the receiver grants credit with WINDOW_UPDATE frames as the application actually consumes — which is the crucial link: a slow application that stops reading stops granting, and the sender stalls at zero credit. QUIC keeps per-stream and connection limits but removes TCP's shared-byte-stream head-of-line coupling between streams. The receive buffer mirrors the send side at the consumer.
Middle row: the control path. The signal path is how 'slow down' travels: window updates at the transport, readiness/onReady callbacks in gRPC, suspension in Kotlin Flow, demand in Reactive Streams. App-level credit — Reactive Streams' request(n) — is the strongest contract: the subscriber explicitly requests n items; a compliant publisher may not exceed outstanding demand, so overflow is impossible by protocol rather than by buffer luck. gRPC exposes the same idea imperatively: check isReady, write while ready, register onReady callbacks — respecting it keeps the HTTP/2 credit system meaningful; ignoring it just moves the pile-up into the library's internal unbounded queue. The overflow policy menu at any full buffer: block/suspend the producer (correct for pipelines that must not lose data — this is how backpressure propagates), drop oldest/newest (correct for telemetry), conflate to latest (correct for state feeds — a price ticker needs the current price, not every tick), fail/evict (correct for fan-out where the slow client must not degrade the herd).
Bottom rows: the protocol reality check. WebSocket's browser API has no receive-side demand signal and only bufferedAmount — a number you must poll — on send: backpressure over WebSocket is therefore an application protocol you build (acks, credits, or bufferedAmount watermarks with pause/resume). WebTransport and modern streams APIs fix this with genuine stream backpressure. gRPC and Reactive stacks ship working demand signaling — the architecture task is wiring it through your own queues. The ops strip: queue-depth and window-stall metrics at every hop, slow-consumer detection with eviction thresholds, and load shedding at the front door when the whole system is the slow consumer.
End-to-end flow
Trace a live market-data fan-out: one Kafka topic of trades → a streaming gateway → 10,000 WebSocket and gRPC subscribers. The gateway consumes Kafka with a bounded fetch (max in-flight records), demultiplexes into per-subscriber bounded queues (size 256, policy: conflate per instrument — each queue holds at most the latest tick per symbol), and per-connection writer tasks drain queues into sockets.
Happy path: queues hover near empty; writers are always ready; end-to-end latency is single-digit milliseconds. Now a subscriber on hotel wifi degrades to 50KB/s. Their TCP receive window shrinks; the gateway's socket send buffer fills; for the gRPC subscriber, HTTP/2 stream credit hits zero and isReady goes false — the writer task stops draining and parks. The per-subscriber queue begins conflating: new ticks replace old ones per symbol, so the phone receives a slower but always-current feed. Total memory impact of the slow client: one bounded queue, ~tens of KB. The other 9,999 subscribers never notice — the whole point of the per-subscriber isolation.
Then the market opens and the aggregate can't keep up: Kafka lag grows. Because the gateway's fetch is bounded, pressure propagates upstream correctly — the topic buffers on the broker (its job), not in gateway heap. Dashboards show consumer lag rising, per-queue conflation counts spiking; capacity, not correctness, is the problem, and autoscaling adds gateway pods. Compare the naive design: unbounded per-subscriber lists and fire-and-forget WebSocket sends. The hotel-wifi client's list grows unboundedly; at heap pressure the process GC-thrashes; p99 for everyone explodes; then OOM takes down all 10,000 connections at the worst moment of the trading day. Same workload, opposite outcomes, and the difference is entirely in where the bounds and policies sit.
One more wrinkle from the WebSocket side: the browser client sends orders upstream on the same socket. The client SDK watches bufferedAmount against a 64KB watermark before each send; crossing it pauses the order queue and surfaces 'degraded connection' to the UI — application-level backpressure standing in for the signal the WebSocket API never provided.