Real-Timestreamingchunkscancellationpartial resultsbackpressureordering

Streaming APIs: Partial Data as a Contract

A streamed response is a sequence of commitments, not one answer. The contract must say what each chunk means, whether early chunks can be trusted before the end, how the stream announces failure mid-flight, and what a consumer resumes after a drop.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When the response arrives in pieces over time, what may the consumer do with the pieces it has — and how does it learn the stream ended well, ended badly, or never really ended at all?
Consumers
Clients that act before the end: UIs rendering LLM tokens as they arrive, exporters writing a 10GB result set to disk row by row, gRPC consumers processing server-stream messages, pipelines tailing bulk feeds.
The promise
Each stream declares its unit (what one chunk is), its prefix semantics (what N chunks mean before the end), an in-band termination signal that distinguishes success from mid-stream failure, cancellation both ways, and a resumption story with known cost.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

What a chunk means: the semantics HTTP won't supply

Transport-level streaming (chunked transfer, HTTP/2 data frames) says only "bytes will keep arriving". The contract on top must define the unit — a token, a complete JSON object per line, a row, a typed event — and, more importantly, the prefix semantics: what is a consumer entitled to do with the first N chunks before the stream ends? For a token stream, the prefix is a growing draft the UI renders and the user reads — provisional by nature. For an export, the prefix is N durable rows the consumer may have already written to disk. For a progress stream, each chunk supersedes the last. Three streams, three different answers to "can I act on what I have?", and consumers guess wrong unless told.

The sharpest edge is failure after a valid prefix. A query that streams 80k rows and then hits an error has already delivered 80k perfectly-formed rows — the consumer's file is four-fifths of a lie unless the stream *ends with a verdict*. This is why streamed responses need an in-band termination signal: a final event: done with a summary (count, checksum), or a terminal error frame with a machine-readable code (The Error Model: Structure Over Apology still applies inside streams — arguably more, since the HTTP status was sent long ago and said 200). A stream that just stops is indistinguishable from a stream that was cut, and "the connection closed" is not a verdict.

The three stream shapes, and what a prefix is worth in each
Shape          Unit            Prefix means…            Ends with…
────────────   ─────────────   ──────────────────────   ─────────────────────
Generative     token / delta   provisional draft —      done + finish_reason
(LLM output)                   render, don't commit     (stop | length | error)

Enumerative    row / object    durable items — safe     done + count/checksum,
(export, query)                to persist as received   or error + last_good_seq

Progressive    state snapshot  latest supersedes all    terminal state event
(progress,                     earlier chunks           (succeeded | failed)
 job status)

Cancellation and backpressure: the stream flows both ways

Streams outlive the moment either side wants them. Client-initiated cancellation must be cheap and honored: the user closed the tab three tokens in, the exporter found what it needed after 2% of the data. The transport gives you the mechanics (connection close, HTTP/2 RST_STREAM, gRPC cancellation) — the contract's job is the semantics: does cancelling the stream cancel the *work*? For a pure read, yes, trivially. For a generation billed per token or a job producing side effects, the docs must say what a cancel stops, what it bills, and what state remains (Long-Running Operations: 202 and the Job Resource for when the work should be a first-class resource instead).

Server-side flow is the mirror problem: a consumer that reads at 1MB/s from a stream produced at 50MB/s forces someone to hold the difference. Transport backpressure (TCP flow control, HTTP/2 window) will push back into your producer — a good default, but the contract should state the consequence: slow consumers get a slower stream (producer pauses), or bounded buffering then disconnect with a too_slow error, or spilled-to-disk delivery. Pretending the question doesn't exist means unbounded buffers and the memory incident that follows (Slow Clients and Backpressure treats this in depth).

Resumption completes the lifecycle. Enumerative streams should be resumable by position — an opaque cursor or sequence per chunk, so a consumer that died at row 812,004 continues from there rather than re-downloading 10GB; this is Cursor Pagination: An Opaque Bookmark, Not a Position wearing a different coat, and the same rules apply (opaque tokens, stated validity window). Generative streams usually declare non-resumability honestly — regenerate instead — because replaying a half-finished generation is rarely meaningful.

  • Cancel semantics, stated — "closing the stream stops computation within 1s; tokens already generated are billed" is a complete clause.
  • Flow consequence, chosen — pause the producer, buffer-with-bound then disconnect, or spill; each is legitimate, silence is not.
  • Resume tokens on enumerative streams — per-chunk cursors with a validity window; re-streaming from zero is the fallback, not the plan.
  • Heartbeats during quiet phases — a long-running query streams nothing while planning; keep-alive frames stop clients and proxies from declaring death (Server-Sent Events).
  • Ordering scope — in-order within one stream is free from the transport; across parallel streams or after resume, only your sequence numbers order anything.

Choosing streaming — and shaping the contract for it

Streaming earns its complexity from one of three pressures: the result is too large to materialize (exports, bulk reads — streaming bounds memory on both sides), the result is produced over time and early parts have value now (tokens, logs, progress), or the consumer needs lower time-to-first-byte than materialize-then-send allows. Absent all three, a plain response or a paginated sequence of plain responses (Pagination: Choosing How Lists End) is strictly simpler: cacheable, retryable, debuggable with curl, and immune to every mid-stream ambiguity this lesson exists to resolve.

When you do stream, shape the format for incremental consumption: newline-delimited JSON (or typed SSE events, or length-prefixed frames) — *not* one giant JSON array, which most parsers can only validate at the closing bracket, quietly re-materializing everything streaming was meant to avoid. And keep a non-streaming sibling where consumers plausibly want one (?stream=false, or the job-then-download pattern): scripts and batch integrations often prefer one complete, verifiable response over incremental delivery.

A stream that is just a long response
1GET /exports/big-query
2200, chunked
3[ {row}, {row}, {row}, … # one JSON array:
4 # parseable only at the end
5# …45 minutes in, the DB times out.
6# Connection closes. No error frame —
7# the client has 3.8GB of valid-looking
8# rows and a truncated array.
9# Resume? Start over from row zero.
A stream with a lifecycle: verdict, resume, cancel
1GET /exports/exp_9/stream
2Accept: application/x-ndjson
3200
4{ "seq": 1, "row": {…} }
5{ "seq": 2, "row": {…} }
6
7{ "done": true, "rows": 812004, "sha256": "9f3…" }
8 # or, on mid-stream failure:
9{ "error": { "code": "source_timeout",
10 "last_good_seq": 640200,
11 "resume": "/exports/exp_9/stream?after_seq=640200" } }
12
13# Client cancel: close the stream —
14# export job keeps its state; resume later.

The first design streams bytes but not meaning: no verdict, no resume, no per-chunk framing — every failure costs the whole transfer and may go unnoticed. The second costs a framing decision and a terminal frame, and buys verifiable completeness, cheap resume and honest mid-stream errors.

Key points

  • Transport streaming promises only "more bytes"; the contract must define the chunk unit and what a prefix is worth before the end.
  • Every stream needs an in-band verdict — done-with-summary or error-with-code — because a stream that just stops is indistinguishable from one that was cut.
  • A 200 status is sent before the work finishes; mid-stream failures must therefore be first-class frames, not connection closes.
  • State cancellation semantics (does closing the stream stop the work? what's billed?) and the slow-consumer consequence (pause, bounded-buffer-disconnect, or spill).
  • Enumerative streams resume by cursor — pagination's rules in a different coat; generative streams may honestly declare regenerate-instead.
  • Stream only under real pressure (size, time-value, TTFB); otherwise plain or paginated responses are simpler in every dimension.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: turns a big query into a chunked response by serializing one huge JSON array straight from the DB cursor.
  2. 2
    Consumer → API: pipes the stream to disk; a mid-stream DB timeout closes the connection after 3.8GB.
  3. 3
    Consumer → data: the truncated file parses as "almost all rows" in a lenient reader; the missing 20% is discovered in a quarterly reconciliation.
  4. 4
    Consumer → API: re-runs the full export on every failure — there is no resume — turning each blip into 45 more minutes of load.
  5. 5
    Ops → API: three concurrent full-restart exports from retrying consumers saturate the source DB; the export feature now causes the incidents it fails on.
What breaks
  • Truncated streams get consumed as complete data — silent partial-data corruption downstream, the enumerative stream's worst case.
  • No-resume streams turn transfer failures into full-restart storms whose cost scales with the data size that justified streaming in the first place.
  • Unbounded buffering for slow consumers converts one misbehaving client into a producer-side memory incident affecting everyone.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Frame chunks individually (NDJSON, typed events, length-prefixed) and end every stream with a machine-readable verdict carrying counts or checksums.
  • • Give enumerative streams per-chunk sequence/cursor and a resume parameter with a stated validity window; declare generative streams non-resumable explicitly.
  • • Document cancellation semantics (work stopped? billed? state kept?) and pick a slow-consumer policy with a named error code for disconnection.
  • • Offer the non-streaming sibling (complete response or job-then-download) where consumer shapes plausibly want verification over incrementality.
Observe in production
  • • Track stream completion by verdict, not connection close: done/error/cut ratios per endpoint expose truncation consumers haven't noticed yet.
  • • Measure consumer read rates and producer buffer depths; the slow-consumer policy needs data before the first memory alert, not after.
  • • Monitor resume usage vs full restarts — a resume feature nobody uses usually means the cursor semantics are broken or undocumented.
Evolve without breaking
  • • New chunk types and fields follow additive rules once consumers ignore unknowns — the same compatibility discipline as any schema ([[backward-compatibility]]).
  • • Framing cannot change in place (array → NDJSON is a breaking change); introduce new framing via content negotiation or a sibling endpoint and migrate ([[api-migration]]).
  • • Verdict frames can gain fields (checksums, timing) freely; removing or renaming terminal event types is a deprecation program.
What it costs
  • • Streams forfeit most HTTP machinery: no response caching, no simple retry semantics, status sent before the outcome is known — every one of those must be re-provided in-band.
  • • Per-chunk framing and verdict frames add format overhead and design surface versus "just serialize the array" — paid once, saved on every failure.
  • • Resume support means server-side positioning state (or deterministic re-computation) with a retention window you must size and honor.

Misconceptions

Claim
“The 200 status means the streamed response succeeded.”
Reality
The 200 was sent when streaming *began* — it certifies the request was accepted, nothing more. Success or failure is decided minutes later, mid-body, which is why it must be signaled in-band by a terminal frame.
Claim
“Streaming is faster than a normal response.”
Reality
It improves time-to-first-byte and bounds memory; total transfer time is the same or slightly worse (framing overhead). If the consumer can't act on partial data, streaming delivered complexity and no speed.
Claim
“TCP guarantees delivery, so consumers get every chunk.”
Reality
TCP guarantees ordered bytes while the connection lives. Connections die; delivery of the whole stream is exactly what is not guaranteed — hence verdicts, sequence numbers and resume tokens at the contract layer.

Apply it