Slow Clients and Backpressure
A streaming or download API produces bytes faster than some consumer can take them. Where do the bytes wait, who runs out of memory first, and when does the server hang up? A contract that does not answer those questions answers them in production — usually by the whole tier falling over together.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Bytes have to wait somewhere
A server writing a 2 GB export to a client reading at 1 MB/s does not "send the file". It writes into a kernel socket buffer; the kernel sends as fast as the client acknowledges; TCP flow control stops the sender when the client's receive window fills (Flow Control: The Receive Window). Above the kernel, the application has its own buffers — the HTTP framework's write queue, the WebSocket library's outbound message queue, whatever the handler awaited on. If the application keeps producing while the socket is stalled, those buffers grow. Unbounded, they grow until the process is out of memory — and because one process serves many clients, one slow client's backlog takes every fast client with it (The Buffer Chain, What Happens When the Receiver Is Slow).
That is the whole problem: something must stop the producer when the consumer is behind, and the contract decides what. Three honest answers exist. *Backpressure*: the producer awaits the write; a stalled socket pauses the query, the file read, the event generation — bounded memory, unbounded time. *Drop*: the server keeps a bounded queue per client and discards (oldest or newest) when full, telling the client it did. *Disconnect*: the server closes when the client falls more than N behind or stays idle longer than T, and the contract says how to resume. Silence — an unbounded queue and hope — is the fourth, and it is the one most frameworks default to.
The choice depends on what the stream *is*. A download or export must not drop bytes: backpressure, with a resumable position for when the connection dies. A live feed of prices or presence updates may drop stale intermediate values as long as the client learns it missed some and can resync. A stream of ordered domain events (see Streaming APIs: Partial Data as a Contract) usually cannot drop, so it backpressures and eventually disconnects the client that cannot keep up — with a cursor so the client catches up later, not from scratch.
Writing the policy into the contract
For downloads and exports the contract clauses are: Content-Length when known (so clients can show progress and detect truncation), Accept-Ranges: bytes with support for Range requests so an interrupted transfer resumes from an offset rather than restarting (HTTP: Requests, Responses, Headers and Status Codes), chunked transfer when the length is unknown, an idle timeout ("if no bytes are acknowledged for 60 s the server closes the connection") and a total ceiling if one exists. Large artifacts often should not stream through the API at all — hand back a signed object-storage URL and let a system built for byte-serving do it (File Upload APIs: Authorize, Upload Directly, Confirm in reverse).
For event streams (SSE, WebSocket) the clauses are about messages, not bytes: a per-connection outbound queue with a stated bound (in messages or bytes), the policy at the bound — pause the producer, coalesce (keep only the latest value per key), drop with a {"type":"gap","from":…,"to":…} marker, or close with a documented code — sequence numbers on every message so gaps are detectable, and a resume cursor (Last-Event-ID for SSE, an explicit resume_from on WebSocket subscribe) so a reconnecting client catches up from where it stopped rather than replaying everything or missing everything (Server-Sent Events, WebSocket Message Contracts). Heartbeats with a miss-count define "dead" so half-open connections do not hold buffers forever.
Disconnect is a legitimate contract outcome, not a failure, if it is *announced* and *resumable*: a WebSocket close code like 4008 SLOW_CONSUMER with the last delivered sequence, an SSE stream ending with a final event: overflow carrying the cursor. The client that is told why it was dropped and where to resume can decide to catch up or to switch to a coarser feed; the client that just sees EOF reconnects instantly and gets dropped again.
Downloads / exports
Content-Length when known · Accept-Ranges: bytes · resume with Range: bytes=<offset>-
Producer is paused while the socket is stalled (no data is ever dropped)
Idle timeout 60 s without acknowledged bytes → connection closed; resume with Range
Artifacts > 256 MB are served from a signed storage URL, not through the API
Event streams (SSE / WebSocket)
Every message carries seq (monotonic per subscription)
Outbound queue per connection: 1,000 messages or 4 MB, whichever first
At the bound: presence/price feeds coalesce per key; ordered event feeds pause the producer up to 10 s, then close 4008 SLOW_CONSUMER { last_seq }
Heartbeat every 15 s; 3 missed → server closes 4009 HEARTBEAT_TIMEOUT
Resume: SSE Last-Event-ID | WS subscribe { resume_from: seq } — replay window 15 min, else 4010 RESUME_EXPIRED → full resync via RESTWhat the server must not do, and what the client must
The server must not buffer without bound, and must not let one connection's backlog consume shared memory: per-connection limits plus a tier-wide budget, so the response to an overrun is closing *that* client, not OOM-killing the process that serves ten thousand others (Memory Pressure, Swap and the OOM Killer). It must not interpret a stalled socket as "the client went away" without a timeout — a mobile client behind a tunnel can legitimately stall for seconds. And it must not hide the policy: if messages can be coalesced or dropped, the docs and the message schema say so, or consumers will build exactly-once assumptions on a best-effort feed.
The client side of the contract is real too. Clients read as they parse — streaming JSON/NDJSON parsers, not await response.text() on a 2 GB body. They honor Range and Last-Event-ID on reconnect instead of restarting. They back off on 4008-style closes rather than reconnecting in a tight loop, which is the client-side version of the same overload. Publishing a reference client or SDK helper for the stream (SDK Design: The Contract's User Interface) is how most providers make those behaviors the default rather than a hope.
HTTP/2 adds a layer: many streams share one connection, with per-stream and per-connection flow-control windows (HTTP/2: Streams on One Connection). One slow stream can stall the connection's window for the others — which is why an API multiplexing a bulk download beside interactive requests on the same connection sees head-of-line effects it did not design for. The contract-level answer is usually to serve bulk bytes from a different host or a signed URL, keeping the interactive API's connections short and cheap.
| Policy | Memory | Data loss | Latency for fast clients | Suits | Contract must say |
|---|---|---|---|---|---|
| Pause producer (backpressure) | Bounded | None | Unaffected if per-connection | Downloads, ordered event feeds | Idle timeout, resume mechanism |
| Coalesce per key | Bounded | Intermediate values | Unaffected | Presence, prices, progress | That only the latest value is guaranteed |
| Drop with gap marker | Bounded | Announced gaps | Unaffected | Telemetry, best-effort feeds | Gap message schema; how to resync |
| Disconnect + resume cursor | Bounded | None (replay window) | Unaffected | Any feed with a replay log | Close code, replay window, resync path |
| Unbounded queue | Unbounded | None — until the process dies, then everything | Degrades for all as memory pressure grows | Nothing | — (the default you did not choose) |
Key points
- A slow consumer means bytes wait somewhere; unbounded application buffers turn one slow client into an outage for every client on the process.
- The contract picks a bound policy per stream type: pause the producer, coalesce, drop with a gap marker, or disconnect with a resume cursor.
- Downloads never drop:
Content-Length,Accept-Ranges, idle timeouts, and a signed storage URL for anything large. - Event streams carry sequence numbers, a bounded queue, heartbeats, an announced close code and a resume window.
- Disconnecting a slow client is legitimate when it is announced and resumable; silent EOF just produces a reconnect storm.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: builds an NDJSON export that reads the full query into memory and writes it to the response, and a WebSocket feed whose library queues outbound messages without limit.
- 2Client → API: a mobile client on a congested link reads the export at 200 KB/s; the connection stalls; the handler keeps producing into the framework's write buffer.
- 3Process → tier: three such clients push the process past its memory limit; the orchestrator kills it; ten thousand healthy connections drop and reconnect at once.
- 4Clients → API: reconnecting feed clients get no resume cursor; each replays from scratch or misses everything in between; some reconnect in a tight loop.
- 5Team → incident: adds a hard per-connection timeout as a hotfix; downloads over 10 minutes now fail at 100% with no
Rangesupport to resume.
- Memory exhaustion on the streaming tier, taking every client down because one was slow.
- Silent data loss on feeds where clients assumed completeness, discovered as reconciliation drift.
- Reconnect storms after disconnects that carried no reason and no cursor.
- Large downloads that cannot complete on real-world links because nothing is resumable.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Bound every outbound buffer per connection and per tier, and make the handler await writes so a stalled socket pauses the producer.
- • State the bound policy per stream type in the contract — pause, coalesce, drop-with-gap, or disconnect — with the numbers.
- • Give downloads `Content-Length`, `Accept-Ranges`/`Range`, and an idle timeout; serve large artifacts from signed storage URLs.
- • Give event streams sequence numbers, heartbeats, announced close codes and a resume cursor with a documented replay window.
- • Publish client guidance (streaming parsers, resume on reconnect, backoff on slow-consumer closes) and ship it in the SDK.
- • Per-connection outbound queue depth, and its maximum across the tier — the slow-consumer early warning.
- • Process memory tracking connection count rather than request rate is unbounded buffering.
- • Close-code counts (`SLOW_CONSUMER`, `HEARTBEAT_TIMEOUT`) per client identify who cannot keep up before they take the tier down.
- • Range-request share on downloads and reconnect-with-cursor share on feeds show whether clients actually resume or restart.
- • Adding `Range` support, sequence numbers, or a resume cursor is additive; tightening a queue bound or idle timeout is breaking for the slowest consumers — announce with telemetry on who would be affected.
- • Moving large artifacts from inline streaming to signed URLs changes the download contract; run both for a window with the old path deprecated.
- • Switching a feed from pause-producer to coalesce changes delivery guarantees — that is a versioned change to the message contract, not a tuning knob.
- • Backpressure ties producer speed to the slowest consumer per connection; long-held database cursors or file handles are the cost.
- • Coalescing and dropping keep memory bounded by giving up completeness; consumers must build resync logic.
- • Replay windows for resume cursors need a retained log per subscription — storage and a retention policy.
- • Signed storage URLs for downloads split the contract across two hosts and complicate auth and observability.
Misconceptions
Range resume exists. The fix for slow downloads is resumability plus an idle (not total) timeout — or not serving the bytes through the API at all.