Backend / Protocols / SSE / 02_streaming_llm_responses.md

Streaming LLM responses over SSE

Updated 4 interview angles 10 min read source
On this page8
  1. Re-streaming the provider
  2. The Stop button, one hop further
  3. Coupled or decoupled runs
  4. Token sizes leak the answer
  5. Where it breaks in production
  6. The trade not taken
  7. Related
  8. Interview angle

Streaming LLM responses over SSE

Article.

Every chat product streams tokens from a model provider, through your backend, to a browser. The protocol is Server-Sent Events, covered in Server-Sent Events (SSE): the wire format, EventSource, resume, buffering proxies, idle timeouts, the disconnect chain inside FastAPI. This note is the LLM half, built around one question: “the user clicks Stop. Walk the bytes. Are you still paying?”

The answer needs three things the protocol note does not cover: what your backend does with the provider’s stream (re-emit your own events, never forward theirs), how the disconnect travels one hop further to the provider, and the design choice between a run that dies with the request and one that survives it. Then the parts nobody expects: billing for tokens nobody saw, and token sizes leaking the answer.

text
provider ─SSE─▶ backend ─SSE─▶ nginx/CDN ─▶ browser
    ▲              │                         │
    └── close ◀────┴──── Stop / tab closed ◀─┘

Re-streaming the provider

The runnable setup below is two FastAPI apps: a fake provider that streams OpenAI Chat Completions-shaped chunks and logs what it generated, and the backend that re-streams it. Start each with uv run --with fastapi --with uvicorn --with httpx uvicorn <file>:app.

The fake provider

python
# provider.py - a fake LLM streaming Chat Completions-shaped chunks
import asyncio, json, logging
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

log = logging.getLogger("uvicorn.error")
app = FastAPI()
WORDS = "Server-Sent Events are one long HTTP response that never ends early".split()

@app.post("/v1/chat/completions")
async def completions():
    async def gen():
        n = 0
        try:
            for n, word in enumerate(WORDS * 3, 1):     # 33 tokens
                await asyncio.sleep(0.1)                 # decode time per token
                chunk = {"choices": [{"delta": {"content": word + " "}}]}
                yield f"data: {json.dumps(chunk)}\n\n"
            yield "data: [DONE]\n\n"
            log.info("provider: finished, billed %d output tokens", n)
        except asyncio.CancelledError:
            log.info("provider: client gone, stopped at %d tokens", n)
            raise
    return StreamingResponse(gen(), media_type="text/event-stream")

The backend reads it inside the generator

python
# app.py
import asyncio, json, logging, uuid
import anyio, httpx
from fastapi import FastAPI
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel

log = logging.getLogger("uvicorn.error")
app = FastAPI()
upstream = httpx.AsyncClient(base_url="http://127.0.0.1:9000", timeout=None)

class Prompt(BaseModel):
    prompt: str

async def provider_tokens(prompt: str):
    body = {"stream": True, "messages": [{"role": "user", "content": prompt}]}
    async with upstream.stream("POST", "/v1/chat/completions", json=body) as r:
        async for line in r.aiter_lines():      # an SDK does this parsing for you
            if line.startswith("data: ") and line != "data: [DONE]":
                yield json.loads(line[6:])["choices"][0]["delta"]["content"]

async def save_partial(run_id: str, n: int) -> None:
    await asyncio.sleep(0.05)                   # a database write
    log.info("chat %s: saved %d tokens", run_id, n)

The endpoint

python
@app.post("/chat", response_class=EventSourceResponse)
async def chat(body: Prompt):
    run_id, sent = uuid.uuid4().hex[:6], 0
    try:
        yield ServerSentEvent(event="run", data={"run_id": run_id}, retry=2000)
        async for tok in provider_tokens(body.prompt):
            sent += 1
            yield ServerSentEvent(event="token", id=str(sent), data={"t": tok})
        yield ServerSentEvent(event="done", id=str(sent + 1), data={})
    except asyncio.CancelledError:
        log.info("chat %s: cancelled after %d tokens", run_id, sent)
        raise
    finally:
        with anyio.CancelScope(shield=True):    # see 01: the finally under cancel
            await save_partial(run_id, sent)

The structural point is that the provider read happens inside the response generator, in its async with. Whatever cancels the generator also closes the provider connection. fastapi.sse (FastAPI 0.135+) frames each event, works for POST, pings after 15 s of silence and sets the no-buffering headers; why the finally needs a shield is in the protocol note.

One event schema of your own

Never forward the provider’s events to the browser. Every provider streams a different shape:

Provider Stream shape End and errors
Chat Completions data: chunks of choices[0].delta data: [DONE]; usage chunk if asked
Responses API named events with sequence_number response.completed, .failed, error
Anthropic Messages content_block_delta events, ping message_stop; event: error mid-stream

Map them onto a schema you own (run, token, tool_call, usage, error, done), with an id on every event for the resume cursor.

  • Raw provider events couple the UI to a vendor, and switching or falling back to another model changes the frontend.
  • They can leak tool arguments, internal tool names, or reasoning summaries you did not mean to show.
  • Errors after the first byte cannot change the 200. They go in-band as event: error, the way Anthropic sends overloaded_error mid-stream, and the client must handle an answer that stops with an error event.
  • Forward usage as a final event, never bill from it: an interrupted stream may not send it at all.

The Stop button, one hop further

The protocol note follows the disconnect from the browser to the generator’s await. In this setup it continues: CancelledError unwinds through async with upstream.stream(...), httpx closes the provider connection, and the provider’s own disconnect handling stops generating. Measured with curl giving up after one second:

text
$ curl -N --max-time 1 localhost:8000/chat --json '{"prompt":"hi"}'
...
data: {"t": "response "}
id: 7

curl: (28) Operation timed out after 1012 milliseconds with 349 bytes received
app.log       INFO:     chat e247c5: cancelled after 7 tokens
              INFO:     chat e247c5: saved 7 tokens
provider.log  INFO:     provider: client gone, stopped at 8 tokens

Seven tokens delivered, eight generated: whatever is in flight when the cancel lands is still produced.

Real providers add more. Prefill of a long prompt is paid as soon as it starts, and no provider documents exactly what is billed after a disconnect (OpenAI says only that closing the connection cancels a synchronous response).

So measure it: a per-request counter of tokens delivered, reconciled against the provider’s usage report. The metering side is in Worked Design — LLM usage metering.

The detached pump: paying after the user left

A common refactor moves the provider read into a background task that fills a queue, so the response generator only drains the queue:

python
async def pump():                         # WRONG: a task nobody cancels
    async for tok in provider_tokens(body.prompt):
        await q.put(tok)
    await q.put(None)

tasks.add(asyncio.create_task(pump()))    # kept alive, as the asyncio docs advise
while (tok := await q.get()) is not None:
    yield ServerSentEvent(event="token", data={"t": tok})
text
$ curl -sN --max-time 1 localhost:8000/chat-detached --json '{"prompt":"hi"}'
(7 token events received)
provider.log  INFO:     provider: finished, billed 33 output tokens

The disconnect cancelled a generator that only read a queue. The task reading the provider had no parent to cancel it, so it generated all 33 tokens for a user who saw 7. Keep the provider read inside the generator, or in a TaskGroup that the generator owns, so cancellation reaches it. This bug is invisible in testing, because every test reads the stream to the end.

Coupled or decoupled runs

The chat() endpoint above is coupled: generation lives and dies with the request. That stops the bill when the user leaves, but a two-second blip on a train is also a disconnect, and then the answer is gone.

Coupled Decoupled run
Disconnect cancels the provider call generation continues
Blip, then reconnect the answer is lost replayed from the log
Tab closed for good billing stops billed to the last token
Moving parts one request task, run log, readers

A decoupled run generates in its own task, writes every event to a log (a Redis Stream: XADD per event), and readers tail the log from their last id (XREAD BLOCK). Replaying a missed tail costs a read, not a regeneration; the replay endpoint is in the protocol note.

The middle path: a grace period

Generation is decoupled, but abandoned runs are cancelled after a short grace period:

  1. Each reader registers on the run; Stop cancels the run task immediately.
  2. When the last reader disconnects, schedule loop.call_later(30, cancel_if_abandoned, run), a plain callback, which is safe to register from a cancelled finally.
  3. A reader that reconnects within 30 s cancels the timer and resumes from its last id; otherwise the run task is cancelled and billing stops.

Managed versions exist. OpenAI’s background responses keep generating without a connection, resume a dropped stream with starting_after=<sequence_number> and stop with POST /v1/responses/{id}/cancel. LangGraph deployments expose the same choice as on_disconnect (continue or cancel).

Long agent runs that outlast every timeout on the path belong in a job with a status URL (Long-running agent jobs).

Token sizes leak the answer

One event per token gives each TLS record roughly one token’s length, and the sequence of lengths is a fingerprint of the text:

  • Weiss et al. (USENIX Security 2024) reconstructed 27% of AI assistant responses from token lengths alone, and inferred the topic of 53%.
  • Microsoft’s Whisper Leak (2025-11-07) inferred prompt topics from packet sizes and timing on streamed responses, across many providers.

Providers now pad: OpenAI adds a random obfuscation field to stream deltas by default. A backend that re-emits one event per token rebuilds the leak on the browser leg. Coalesce tokens per frame on a short timer (say 30-50 ms, below what a reader notices), or pad events to size buckets.

Where it breaks in production

  • Billed tokens far above delivered tokens: a detached pump, or a decoupled run with no grace-period cancel. Signal: billed minus delivered, per feature.
  • The frontend breaks when the model changes: provider events forwarded raw, and the fallback model streams a different shape.
  • Answers that silently stop: an error after the first byte with no in-band error event, so the UI waits forever.
  • Lost answers on flaky networks: coupled runs with no log to resume from, on mobile clients.
  • Cost logs of zero: usage taken from the stream, which an interrupted stream never sent.
  • Every token arriving at once in production: a buffering hop, covered with the probe and the fixes in the protocol note.
  • Twenty open chats exhaust the database pool: a request-scoped yield dependency held for the whole stream (also in the protocol note).

What to watch

  • time to first token at the provider against time to first event at the browser: the gap is your stack;
  • open streams and open run tasks, as gauges (len(asyncio.all_tasks()) creeping up means leaked pumps);
  • billed minus delivered tokens;
  • runs ended by error events, per provider and model.

The trade not taken

WebSocket to the browser. It wins when the client speaks mid-run (voice, interrupting with new input, collaborative editing). For token streaming the traffic is one-way, and WebSocket brings its own reconnect, resume and auth story for no gain. OpenAI’s Responses API WebSocket mode does not change this: it speeds up the backend-to-provider tool loop (OpenAI reports up to about 40% faster on runs with 20 or more tool calls), not the browser leg.

NDJSON over fetch costs little to offer as well (FastAPI streams application/jsonl from a yielding path operation), but has no ids, retry or heartbeat convention; SSE’s framing is worth keeping.

Interview angle 4

  • “The user clicks Stop. Walk the bytes. Are you still paying?” - the abort closes the connection, each proxy closes upstream, Starlette cancels the response generator, and CancelledError unwinds through the async with holding the provider stream, which closes it; the provider stops. Tokens in flight are still billed.
    • Follow-up: you moved the provider call into a background task and the bill went up. Why? The task has no parent to cancel it: it generates the whole answer for a user who left. Keep the read inside the generator or a task group it owns.
    • Follow-up: how do you know what you paid for? Count delivered tokens per request and reconcile with the provider’s usage report; usage in the stream is missing exactly when the stream was cut.
  • “Why not forward the provider’s SSE stream straight to the browser?” - it couples the UI to one vendor’s event shape, can leak tool arguments and reasoning, and leaves you without your own ids for resume.
    • Follow-up: what happens to an error halfway through? The status is already 200, so it goes in-band as an error event the client handles.
  • “A user on a train loses connection for three seconds. What do they see?” - with a coupled run, a lost answer; with a decoupled run and a log, a resumed one from their last event id.
    • Follow-up: then why not always decouple? A closed tab would be billed to the last token. A grace period cancels runs nobody comes back to.
  • “Is streaming one event per token a security problem?” - token lengths in TLS record sizes let an observer reconstruct responses (27% in Weiss et al.); coalesce or pad events on the browser leg.