Skip to content
Residual Stream
Go back

Streaming Tokens to the User: SSE, WebSockets, and the Wait the Reader Feels

Updated:
Tan Le
ENVI

Decoding turned a model’s distributions into tokens, one forward pass each. A 300-word answer is about 400 tokens, and at 20 to 50 milliseconds a token that’s eight to twenty seconds of generation. Nothing about decoding can change that the tokens come out one at a time. What can change is when the reader sees them.

Streaming sends each piece of text as soon as it exists, instead of the whole answer at the end. It doesn’t make generation faster: the last token arrives when it would have anyway. It changes which wait the reader feels. This post builds a streaming server from the standard library, measures what it buys, and goes through what breaks it in practice.

not streamed generating 100 tokens the reader waits: 4.68 s streamed (SSE) first text after 0.13 s 0 s 1 s 2 s 3 s 4 s 5 s

What streaming changes. Generation takes the same time either way; the reader’s wait drops from the whole answer to the first token. (Token arrivals are drawn evenly.)

Data: the measurement later in the post
At a glance
  • Streaming doesn’t make generation faster; it moves the wait the reader feels from the whole answer (4.7 s) to the first token (0.13 s).
  • Model APIs stream with server-sent events: one long HTTP response, a data: line per event, 5.4 times the text’s size on the wire here. WebSockets add a channel back to the server.
  • Send text, not tokens: one character can span several tokens, and decoding a test sentence one token at a time broke 6 pieces, against 0 decoded incrementally.
  • Stop generating when the user leaves: a server that didn’t check generated 100 tokens for a client that left after 10.
  • A buffering proxy turns a stream back into one late response: the first text came after 4.91 s through one, against 0.20 s when it let the stream through. Test the time to the first event through the whole path.

Table of contents

Open Table of contents

Five ways to get text to a browser

The web was built for request and response: the client asks, the server answers, the connection is done. Generated text doesn’t fit that shape, since the answer exists only gradually. There are five standard ways to deal with it:

how it worksfirst text aftercost
request/responseone answer, sent when completethe whole generationsimplest; nothing to show meanwhile
pollingthe client asks “anything new?” every Δt\Delta tup to Δt\Delta t late, each timemany requests, most of them empty
long pollingthe server holds each request open until there’s something newabout one round trip latea new request per piece
server-sent events (SSE)one long HTTP response, the server writes events into itimmediatelyone direction only, server to client
WebSocketthe connection upgrades to a two-way message channelimmediatelyits own protocol; proxies and load balancers must support it
polling client server most answers empty long polling client server a new request per piece SSE client server POST one response, many events WebSocket client server upgrade stop messages both ways

Four of the five ways to get text to a browser, as messages over time. SSE is what model APIs use; WebSockets add the arrow back.

Nearly every model API streams with SSE: OpenAI’s, Anthropic’s and Google’s all do. It’s plain HTTP, so it passes through the same infrastructure as everything else, and generation only needs the server-to-client direction. WebSockets come in when the client must also talk during the stream, as voice interfaces do (see System Design for AI); OpenAI’s Realtime API, for voice, uses WebSockets or WebRTC.

The SSE wire format

An SSE response is an ordinary HTTP response with the content type text/event-stream, kept open, into which the server writes events as lines of text (WHATWG, HTML Living Standard):

  • data: ... lines carry the payload; several data lines in one event are joined with newlines;
  • event: ... names the event’s type, id: ... gives it an identifier, and retry: ... tells the client how long to wait before reconnecting;
  • a blank line ends the event and dispatches it;
  • a line starting with : is a comment, ignored, which makes it the way to send keep-alive pings through proxies that close idle connections.

If the connection drops, the browser’s EventSource reconnects by itself and sends the last id it saw in a Last-Event-ID header, so the server can resume. But EventSource can only make GET requests, with no request body and no custom headers, and a model API needs to POST a prompt and an API key (which belongs on the application’s backend, never in the browser). So clients for model APIs read the response stream with fetch and parse the events themselves, and those conveniences don’t apply. They’d need help anyway: EventSource reconnects after a stream ends normally too, unless the server answers with status 204, and resuming at Last-Event-ID only works if the server kept what it already generated. Otherwise a reconnect starts generation over, and bills it again.

How the stream says it’s finished is a convention, not part of SSE:

  • OpenAI’s Chat Completions end with a last event whose data is the literal [DONE]; its Python SDK stops reading when an event’s data starts with [DONE]. OpenAI’s newer Responses API uses named, typed events instead (response.output_text.delta, …, response.completed).
  • Anthropic’s Messages API names every event: message_start, then for each content block content_block_start, a series of content_block_delta, and content_block_stop, then one or more message_delta and a final message_stop, with ping events anywhere and an error event if something fails mid-stream. New event types may be added, so a client should ignore ones it doesn’t know.
HTTP/1.1 200 OK Content-Type: text/event-stream an ordinary response, kept open ⏎ data: {"text": "The"} one event ⏎ ← a blank line ends it data: {"text": " KV"} ⏎ : ping a comment: keep-alive, ignored ⏎ data: [DONE] the end, by convention (OpenAI) ⏎

An SSE stream, byte for byte. Everything is plain text: data: lines, blank lines between events, comment lines to keep the connection alive.

The server below uses the OpenAI-style sentinel, with one JSON object per piece of text.

A streaming server, from the standard library

The server, server.py, runs on asyncio with no web framework, with choose from Decoding for sampling and Qwen2.5-1.5B-Instruct as the model. It answers three ways: POST /complete returns the whole answer as one JSON response at the end, POST /stream sends server-sent events, and GET /ws opens a WebSocket. Generation runs in a thread and hands each piece of text to the network side through a queue; before every token it checks a stop flag:

def generate(prompt, n, out, stop, loop):
    """Sample up to n tokens (top-p 0.9, seeded), pushing text pieces to the
    asyncio queue `out`; a None marks the end."""
    m, ids = model(), torch.tensor([tok(prompt).input_ids], device=DEVICE)
    g = torch.Generator(device=DEVICE).manual_seed(0)
    d, history, produced = Detokenizer(), [], 0
    with torch.no_grad():
        result = m(ids, use_cache=True)
        for _ in range(n):
            if stop.is_set():
                break
            token = choose(result.logits[0, -1], history, g, p=0.9)
            history.append(token); produced += 1
            if token == tok.eos_token_id:
                break
            loop.call_soon_threadsafe(out.put_nowait, d.push(token))
            result = m(torch.tensor([[token]], device=DEVICE), past_key_values=result.past_key_values, use_cache=True)
    loop.call_soon_threadsafe(out.put_nowait, None)
    return produced

Full script: server.py

The request handler answers /complete with the whole text at once. Its SSE branch writes each piece as an event, and a second task watches the connection for the client leaving:

    if path == "/complete":
        pieces = []
        while (piece := await out.get()) is not None:
            pieces.append(piece)
        payload = json.dumps({"text": "".join(pieces)}).encode()
        writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
                     b"Content-Length: %d\r\nConnection: close\r\n\r\n" % len(payload) + payload)
    else:
        writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\n"
                     b"X-Accel-Buffering: no\r\nConnection: close\r\n\r\n")
        if not NAIVE:                                     # the client closing its side means it's gone
            asyncio.create_task(watch(reader, stop))
        while (piece := await out.get()) is not None:
            if piece:
                writer.write(b"data: " + json.dumps({"text": piece}).encode() + b"\n\n")
                try:
                    await writer.drain()
                except ConnectionError:
                    left = True
                    if not NAIVE:
                        stop.set()
        writer.write(b"data: [DONE]\n\n")
        left = left or stop.is_set()
    LOG.append((path, await job, left))
    print(f"{path}: generated {LOG[-1][1]} tokens, client left early: {left}", flush=True)
    try:
        await writer.drain(); writer.close()
    except ConnectionError:
        pass

async def watch(reader, stop):
    await reader.read()                                   # returns b"" when the client disconnects
    stop.set()

Full script: server.py

The SSE response has neither a Content-Length nor chunked encoding, so its end is the connection closing. That’s legal HTTP/1.1, but it means a dropped connection looks like a finished answer at the HTTP level, which is one thing the [DONE] event is for: it tells the client the end was meant. Frameworks send chunked encoding instead, and HTTP/2 and HTTP/3 carry the body in frames. This is a teaching server, not a production one: it parses headers loosely and handles few errors.

The WebSocket part is the protocol written out (RFC 6455). The client asks to upgrade with a random key; the server proves it understood by hashing the key with a fixed string from the RFC and sending the hash back, and from then on both sides exchange frames: a byte with the frame’s type and a “final fragment” bit, the payload’s length in one, three or nine bytes, and the payload. Frames from the client must be masked: XORed with four random bytes the browser chooses and the page’s script can’t predict. Without that, a script could send bytes that an old proxy which doesn’t understand WebSockets reads as a fresh HTTP request, and the proxy would then cache the attacker’s reply as the response for some other site’s URL (RFC 6455, §10.3). The upgrade, the stream with its “stop” listener, and writing a frame and reading a masked one:

async def websocket(reader, writer, headers):
    accept = base64.b64encode(hashlib.sha1((headers["sec-websocket-key"] +
                              "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest())
    writer.write(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
                 b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n")
    request = json.loads(await read_frame(reader))
    out, stop, job = await start(request["prompt"], request.get("max_tokens", 100))
    async def listen():                                   # the client can talk while the server streams
        while True:
            message = await read_frame(reader)
            if message is None or json.loads(message).get("stop"):
                stop.set(); return
    listener = asyncio.create_task(listen())
    while (piece := await out.get()) is not None:
        if piece:
            writer.write(frame(json.dumps({"text": piece}).encode()))
    writer.write(frame(json.dumps({"done": True}).encode()))
    await writer.drain()
    LOG.append(("/ws", await job, stop.is_set()))
    print(f"/ws: generated {LOG[-1][1]} tokens, client said stop: {stop.is_set()}", flush=True)
    listener.cancel(); writer.close()

def frame(payload, opcode=0x1):
    """A server-to-client frame: FIN + opcode, then the length, then the data."""
    n = len(payload)
    head = bytes([0x80 | opcode]) + (bytes([n]) if n < 126 else bytes([126]) + n.to_bytes(2, "big"))
    return head + payload

async def read_frame(reader):
    """A client-to-server frame; clients must mask their data with 4 random bytes."""
    try:
        b0, b1 = await reader.readexactly(2)
    except asyncio.IncompleteReadError:
        return None
    n = b1 & 0x7F
    if n == 126:
        n = int.from_bytes(await reader.readexactly(2), "big")
    mask, data = await reader.readexactly(4), await reader.readexactly(n)
    if b0 & 0x0F == 0x8:                                  # a close frame
        return None
    return bytes(b ^ mask[i % 4] for i, b in enumerate(data)).decode()

Full script: server.py

This server leaves out what a real WebSocket server needs. It doesn’t answer pings (opcode 9) with pongs, and since it treats every frame as text, a client library that sends a ping, as Python’s websockets does every 20 seconds by default, would break its “stop” listener. It doesn’t send its own close frame at the end, so a browser reports an unclean close. It has no 64-bit lengths, no fragmented messages, and no check of the Origin header, which matters because browsers don’t apply CORS to WebSockets and do send cookies: without that check, any page can open a socket to it as the user.

A trap in sending text one token at a time

The server doesn’t send tok.decode([token]) for each token. A token is a piece of a byte sequence, and one character can span several tokens:

from lm import tok

# Streaming text one token at a time has a trap: a token is a piece of a
# byte sequence, not of a string, and one character can span two tokens.
# Decoding each token alone then prints broken characters. The fix is
# incremental: decode everything so far, and emit only the new text once it
# no longer ends in a half-finished character.
class Detokenizer:
    def __init__(self):
        self.ids, self.sent = [], ""
    def push(self, token_id):
        """Add a token; return the new text that is safe to send (may be '')."""
        self.ids.append(token_id)
        text = tok.decode(self.ids)
        if text.endswith("�"):            # the last character isn't complete yet
            return ""
        new, self.sent = text[len(self.sent):], text
        return new

Full script: detokenizer.py

from lm import tok
from detokenizer import Detokenizer

# A character split across tokens, decoded one token at a time and
# incrementally.
text = "Con vẹt 🦜 nói: mô hình sinh văn bản từng token một 🫠"
ids = tok(text).input_ids
naive = [tok.decode([i]) for i in ids]
print(f"{len(ids)} tokens; decoded one by one:")
print("  " + " | ".join(naive))

d = Detokenizer()
pieces = [d.push(i) for i in ids]
print("incremental:")
print("  " + " | ".join(pieces))
print(f"broken pieces one by one: {sum('�' in p for p in naive)}, incremental: "
      f"{sum('�' in p for p in pieces)}; joined text equal to the original: {''.join(pieces) == text}")

Full script: split_chars.py

20 tokens; decoded one by one:
  Con |  v | ẹ | t |  � | � | � |  nói | : |  mô |  hình |  sinh |  văn |  bản |  từng |  token |  một |  � | � | �
incremental:
  Con |  v | ẹ | t |  |  |  🦜 |  nói | : |  mô |  hình |  sinh |  văn |  bản |  từng |  token |  một |  |  |  🫠
broken pieces one by one: 6, incremental: 0; joined text equal to the original: True
decoded one by one Con ␣v ẹ t ␣� � � ␣nói incremental Con ␣v ẹ t held held ␣🦜 ␣nói 🦜 is three tokens a byte-level token can end mid-character: send text only once its last character is complete

Why a server sends text, not tokens. The parrot is three byte-level tokens; decoded one at a time it arrives as three broken characters.

Data: streaming-tokens-sse-websockets/split_chars.py

The parrot and the melting face are each split across three tokens, and decoding them one at a time produces replacement characters � that no later token can repair, since each piece was already sent. The incremental version holds a piece back until its last character is complete. The Vietnamese words in this sentence happen to split only between whole characters (“v”, “ẹ”, “t”), but emoji, rarer scripts and unusual accented letters hit this constantly with a byte-level tokenizer. Three caveats. Decoding the whole text every step costs time proportional to its length; production detokenizers decode only a short window of recent tokens (vLLM’s offsets, Hugging Face’s DecodeStream). This one never flushes: whatever it holds back when generation ends must be sent then. And it assumes decoding a prefix of the tokens gives a prefix of the text, which holds for byte-level BPE like Qwen’s but not for every tokenizer.

Measured: the first text, and the bytes

A client written on raw sockets, so it can time every byte and count it:

Show the client and the measurement script
async def post(path, prompt, n, stop_after=None):
    """POST a prompt; returns (seconds to first text, seconds to the end,
    bytes received, pieces of text). Closes early after `stop_after` events."""
    r, w = await asyncio.open_connection("127.0.0.1", PORT)
    body = json.dumps({"prompt": prompt, "max_tokens": n}).encode()
    t0 = time.perf_counter()
    w.write(b"POST %s HTTP/1.1\r\nHost: x\r\nContent-Length: %d\r\n\r\n" % (path.encode(), len(body)) + body)
    first, received, pieces, buffer = None, 0, [], b""
    head_done = False
    while chunk := await r.read(65536):
        received += len(chunk); buffer += chunk
        if not head_done and b"\r\n\r\n" in buffer:
            head_done, buffer = True, buffer.split(b"\r\n\r\n", 1)[1]
        if head_done and path == "/stream":
            while b"\n\n" in buffer:
                event, buffer = buffer.split(b"\n\n", 1)
                data = event[len(b"data: "):]
                if data != b"[DONE]":
                    first = first or time.perf_counter() - t0
                    pieces.append(json.loads(data)["text"])
            if stop_after and len(pieces) >= stop_after:
                w.close(); await asyncio.sleep(1.0)       # leave, then give the server a moment to notice
                return first, time.perf_counter() - t0, received, pieces
    if path == "/complete":
        first = time.perf_counter() - t0
        pieces = [json.loads(buffer)["text"]]
    return first, time.perf_counter() - t0, received, pieces

Full script: client.py

websocket in the same file does the same over a WebSocket, and can send “stop” partway through. measure.py starts the server in the background and times each of the three endpoints on five prompts:

async def main():
    rows = {"/complete": [], "/stream": [], "/ws": []}
    for prompt in prompts:
        rows["/complete"].append(await post("/complete", prompt, 100))
        rows["/stream"].append(await post("/stream", prompt, 100))
        rows["/ws"].append(await websocket(prompt, 100))
    for name, rs in rows.items():
        first = sum(r[0] for r in rs) / len(rs); total = sum(r[1] for r in rs) / len(rs)
        text = ["".join(r[3]) for r in rs]
        received = sum(r[2] for r in rs); chars = sum(len(t.encode()) for t in text)
        print(f"{name:10s} first text after {first:5.2f} s, all of it after {total:5.2f} s; "
              f"{received / 5:6.0f} bytes on the wire per answer, for {chars / 5:4.0f} bytes of text "
              f"({received / chars:.1f}x)")
    same = all("".join(a[3]) == "".join(b[3]) == "".join(c[3]) for a, b, c in zip(*rows.values()))
    print(f"the same text all three ways: {same}")

Full script: measure.py

Five of the prompts from Decoding, 100 tokens each, delivered all three ways, on an Apple M4 laptop (all of this post’s code runs in about 2 minutes there, once the model is downloaded):

/complete  first text after  4.68 s, all of it after  4.68 s;    596 bytes on the wire per answer, for  488 bytes of text (1.2x)
/stream    first text after  0.13 s, all of it after  4.75 s;   2626 bytes on the wire per answer, for  488 bytes of text (5.4x)
/ws        first text after  0.13 s, all of it after  5.12 s;   2038 bytes on the wire per answer, for  488 bytes of text (4.2x)
the same text all three ways: True

The same text arrives all three ways, and it finishes at about the same time, 4.7 to 5.1 seconds; one run of five prompts is too small to rank the three on that. What differs is the first text: 4.68 seconds without streaming, about 0.1 to 0.2 seconds with either kind of streaming (0.13 on average here). That is the time to first token (TTFT), measured from sending the request: the prompt’s forward pass plus one sampling step. These prompts are short, the first 200 characters of a paragraph each. With a 10,000-token prompt, or a queue of other requests ahead, TTFT grows, and that is where Serving Metrics and Prefill–Decode Disaggregation picks it up.

Streaming costs bytes. Each piece of text, about 5 bytes, travels inside an event: data: {"text": "..."} and a blank line. For this server that’s 5.4 times the text’s own size, 4.2 with WebSocket frames, against 1.2 for one JSON response. Real APIs carry more per event:

import json

# What one streamed piece of text, "Two", costs on the wire in different
# formats, as compact JSON. The Anthropic event is the documented example's
# shape; the OpenAI chunk has the fields of the example in OpenAI's cookbook
# (current chunks also carry a padding field, below).
text = "Two"
ours = b"data: " + json.dumps({"text": text}).encode() + b"\n\n"
anthropic = (b"event: content_block_delta\ndata: " + json.dumps(
    {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}},
    separators=(",", ":")).encode() + b"\n\n")
openai = b"data: " + json.dumps(
    {"id": "chatcmpl-9lMgfRSWPHcw51s6wxKT1YEO2CKpd", "object": "chat.completion.chunk", "created": 1721075653,
     "model": "gpt-july-test", "system_fingerprint": "fp_e9b8ed65d2",
     "choices": [{"index": 0, "delta": {"content": text}, "logprobs": None, "finish_reason": None}]},
    separators=(",", ":")).encode() + b"\n\n"
payload = json.dumps({"text": text}).encode()
websocket = bytes([0x81, len(payload)]) + payload            # a server-to-client frame: 2 bytes of header
for name, b in (("this post's SSE event", ours), ("Anthropic content_block_delta", anthropic),
                ("OpenAI chat.completion.chunk", openai), ("this post's WebSocket frame", websocket)):
    print(f"{name:31s} {len(b):4d} bytes for {len(text.encode())} bytes of text")

Full script: event_bytes.py

this post's SSE event             23 bytes for 3 bytes of text
Anthropic content_block_delta    118 bytes for 3 bytes of text
OpenAI chat.completion.chunk     257 bytes for 3 bytes of text
this post's WebSocket frame       17 bytes for 3 bytes of text
bytes on the wire to carry 3 bytes of text this post’s WebSocket frame 17 this post’s SSE event 23 Anthropic content_block_delta 118 OpenAI chat.completion.chunk 257

Streaming’s overhead per piece of text. It looks wasteful, but at tens of events a second it is tens of kilobytes a second.

Data: streaming-tokens-sse-websockets/event_bytes.py

An OpenAI chunk repeats the response’s identifier, model name and timestamp in every event: about 257 bytes to carry three bytes of text. Current chunks are larger still, and vary: OpenAI now pads each one with a random-length obfuscation field by default, because otherwise the size of each encrypted event reveals each token’s length to anyone watching the network, enough to reconstruct the opening of 29% of responses closely and infer the topic of 55% (Weiss et al., 2024). That sounds wasteful and mostly isn’t: at 20 to 100 events a second it’s tens of kilobytes a second, nothing next to what a web page loads. Bytes start to matter for many concurrent streams through one proxy, and for compression, below.

What the reader feels

Streaming helps because people read slower than models write:

import json
from lm import tok

# Is generation faster than reading? Tokens per word on the blog's own
# prose (the 20 real continuations from "Decoding"), against a reader's speed: 238
# words a minute for non-fiction, the average across studies (Brysbaert,
# 2019). Generation speed is from measure.py: 100 tokens in about 4.7 s.
texts = [p["human"] for p in json.load(open("prompts.json"))]
tokens = sum(len(tok(t).input_ids) for t in texts)
words = sum(len(t.split()) for t in texts)
per_word = tokens / words
reading = 238 / 60 * per_word                              # tokens a second a reader gets through
generating = 100 / 4.7
print(f"{per_word:.2f} tokens per word; a reader at 238 words a minute takes in {reading:.1f} tokens a second; "
      f"this laptop generates {generating:.0f}, {generating / reading:.1f} times as fast")
for n_words in (50, 300):
    n = n_words * per_word
    print(f"a {n_words}-word answer ({n:.0f} tokens): {n / generating:.1f} s to generate, {n_words / 238 * 60:.0f} s to read; "
          f"without streaming the reader waits {n / generating:.1f} s before the first word, with it about 0.13 s")

Full script: reading_speed.py

1.38 tokens per word; a reader at 238 words a minute takes in 5.5 tokens a second; this laptop generates 21, 3.9 times as fast
a 50-word answer (69 tokens): 3.2 s to generate, 13 s to read; without streaming the reader waits 3.2 s before the first word, with it about 0.13 s
a 300-word answer (415 tokens): 19.5 s to generate, 76 s to read; without streaming the reader waits 19.5 s before the first word, with it about 0.13 s
100 tokens: what has arrived, and what a reader has read
05010005101520seconds since the requesttokensnot streamednot streamed — 0: 0not streamed — 5: 0not streamed — 5: 100not streamed — 20: 100streamedstreamed — 0: 0streamed — 0: 1streamed — 5: 100streamed — 20: 100readread — 0: 0read — 18: 100

Hover or focus a point to read its value.

A reader takes in about 5.5 tokens a second; even a laptop generates four times that. So once the first piece arrives, the reader is never waiting: the text runs ahead of them. Without streaming, a 300-word answer means 20 seconds of nothing. With it, 0.13 seconds, then reading. The perceived latency is the time to first token, not the total, as long as generation stays ahead of reading. That second condition is the one serving systems have to protect under load: the time per output token, TPOT, from Serving Metrics and Prefill–Decode Disaggregation is the number that must stay below about 180 ms (one token per 1/5.51/5.5 seconds) for a reader never to catch up. That’s an average, and averages hide stalls: a pause while the server runs someone else’s long prompt is what a reader notices, so Serving Metrics and Prefill–Decode Disaggregation looks at the slowest gaps between tokens, not only the mean. (And 238 words a minute is for English prose; the tokens per word differ for code and for other languages, and readers skim.)

When the user leaves

A user closes the tab halfway through an answer. What should the server do? Stop: every further token costs a forward pass, and the request’s KV cache, which can be gigabytes for a long context (see The KV Cache), stays allocated until generation ends. Two runs of the same experiment, one against the server above, which watches its connection, and one against the same server started with --naive, which doesn’t. The script reads how many tokens the server generated from its GET /log endpoint, which returns the server’s record of each request:

Show the experiment script
async def main(which):
    if which == "leave":
        await post("/stream", PROMPT, 100, stop_after=10)
        await asyncio.sleep(5)                            # let a server that didn't notice finish
        path, generated, left = (await server_log())[-1]
        print(f"SSE client left after 10 pieces: the server generated {generated} tokens in all")
    if which == "stop":
        await websocket(PROMPT, 100, stop_after=10)
        path, generated, stopped = (await server_log())[-1]
        print(f"WebSocket client said stop after 10 pieces: the server generated {generated} tokens in all")
    if which == "proxy":
        client.PORT = 8766
        first, total, received, pieces = await post("/stream", PROMPT, 100)
        print(f"through the proxy: first text after {first:.2f} s, the end after {total:.2f} s, "
              f"{len(pieces)} pieces, {received:,} bytes")

Full script: experiments.py

experiments.py starts the server in the background and runs main once per experiment: the client leaving, against the server and then against the server restarted with --naive; the WebSocket “stop”; and the proxy of the next section, without and with --honor.

Watching the connection:

SSE client left after 10 pieces: the server generated 10 tokens in all

Started with --naive:

SSE client left after 10 pieces: the server generated 100 tokens in all

The server that notices stops at 10 tokens; the naive one generates all 100, 90 of them for nobody. (The 11th forward pass was already running when the client left, and its token was discarded: a check per token stops within one token, and a batched serving engine checks once per step.)

How the server notices matters, and no signal works everywhere:

  • Writing: after the client closes, the operating system accepts the next write and the one after fails, so on a direct connection a server that only watches for write errors stops about one token late.
  • Reading, as here: the read returns end-of-file the moment the client closes, so the stop is immediate. But end-of-file isn’t the same as gone: a client that half-closes its sending side after the request would look like it left.
  • Behind a proxy, the server sees the proxy’s connection, not the user’s. nginx closes the upstream connection when its client aborts, by default; other hops vary.
  • Over HTTP/2 or HTTP/3, cancelling one request resets one stream on a connection that stays open, so no end-of-file ever arrives; the server must watch for the stream reset.
  • A client whose network just vanishes, a phone going to sleep, sends nothing at all, and neither signal fires until TCP gives up, minutes later.

Frameworks expose the transport’s own signal (Starlette’s request.is_disconnected(), or the disconnect message that cancels a StreamingResponse), serving engines abort the request on it, and a write timeout or a heartbeat catches the rest. The same check belongs on the non-streaming endpoint: a closed tab wastes a /complete request just as much, and this server doesn’t check there at all.

With a WebSocket the client doesn’t have to leave to stop. It sends a message on the same connection:

WebSocket client said stop after 10 pieces: the server generated 10 tokens in all
server watches the connection 10 tokens naive server 100 tokens the client leaves after 10 pieces red: 90 tokens generated for nobody

A closed tab, two servers. Without a disconnect check, the GPU keeps generating, and the KV cache stays allocated, for nobody.

Data: streaming-tokens-sse-websockets/experiments.py

With SSE, which only goes one way, “stop” means closing the connection, or a second request to a cancel endpoint.

Backpressure is the other direction of the same problem: the client is there but slow, a phone on a poor network. Here the generation thread puts pieces into an unbounded queue, so a slow reader makes the queue grow while the model runs at full speed. A bounded queue makes the generator wait when the reader falls behind; whether that’s wanted depends on whether the GPU time is better spent on other users. Text is small enough that most servers buffer it and move on, but it’s a decision, not a default.

The proxy that undoes it all

Between a user and a model server there are usually several machines: a CDN, a load balancer, a reverse proxy such as nginx. Proxies can buffer responses, collecting upstream data before passing it on, which is efficient for ordinary pages and fatal for a stream. Whether a given hop does depends on the product and its configuration. nginx’s proxy_buffering, on by default, stores the response in buffers of one memory page (4 or 8 KB) as it arrives; a response cache, a gzip filter, an application server’s own buffering, or some load balancers and CDNs hold data back. A stand-in for the worst case: a proxy that forwards only in 8 KB blocks, and that, started with --honor, respects X-Accel-Buffering: no, the response header nginx honors by default to turn buffering off for one response:

async def handle(client_r, client_w):
    up_r, up_w = await asyncio.open_connection("127.0.0.1", 8765)
    asyncio.create_task(pipe_up(client_r, up_w))
    buffered, head, passthrough = b"", None, False
    while data := await up_r.read(65536):
        if head is None:
            head = data.split(b"\r\n\r\n", 1)[0]
            passthrough = HONOR and b"x-accel-buffering: no" in head.lower()
        if passthrough:
            client_w.write(data); await client_w.drain(); continue
        buffered += data
        while len(buffered) >= BLOCK:
            client_w.write(buffered[:BLOCK]); buffered = buffered[BLOCK:]; await client_w.drain()
    client_w.write(buffered); await client_w.drain(); client_w.close()

Full script: proxy.py

The last two runs of experiments.py send the same prompt through it.

Buffering:

through the proxy: first text after 4.91 s, the end after 4.91 s, 100 pieces, 2,583 bytes

With --honor:

through the proxy: first text after 0.20 s, the end after 5.03 s, 100 pieces, 2,583 bytes
model server proxy forwards 8 KB blocks browser 100 small events one 2.6 KB block buffering: first text after 4.91 s, with the last X-Accel-Buffering: no honored: first text after 0.20 s

How a proxy undoes streaming. Every piece of a short answer fits in one buffer, so the reader gets nothing until the end.

Data: streaming-tokens-sse-websockets/experiments.py

Through the buffering proxy the first text arrives after 4.91 seconds, with the last: the whole 2.6 KB answer fit in one block, so the user got none of streaming’s benefit, and the server and the client both behaved correctly. When the proxy honors the header, the first text arrives after 0.20 seconds, close to the 0.13-second average the five prompts got with no proxy at all. The header is nginx’s convention and only helps where a hop honors it; other proxies, load balancers and CDNs have their own settings, which have to be checked one by one. The other usual suspects:

  • compression: gzip middleware buffers to compress well; streams must be excluded or flushed per event;
  • idle timeouts: nginx closes an upstream connection that’s silent for 60 seconds by default, an AWS load balancer after 60, Cloudflare after about 100, and a long reasoning step can be that quiet. A comment line (: ping) every 15 seconds or so, as the SSE specification suggests, keeps the connection alive; Anthropic sends ping events for the same reason;
  • connection limits: over HTTP/1.1 a browser opens at most six connections to one host, shared by all its tabs, and each open stream holds one. HTTP/2 and HTTP/3 multiplex streams over one connection, up to a limit the server sets, usually 100.

The test is always the same: measure the time to the first event, not the first byte (a buffering hop often passes the headers on at once), through the whole path a user’s request takes, not against the server alone.

What real streams also carry

Text deltas are the simple case. A production client also has to handle:

  • Usage at the end. OpenAI sends token counts in a final chunk, when asked, whose choices list is empty, which crashes code that reads choices[0]; Anthropic’s message_delta carries cumulative usage.
  • Tool calls as partial JSON. A function call’s arguments stream as fragments of a JSON string (OpenAI’s arguments deltas, Anthropic’s input_json_delta). Accumulate them and parse once the block ends, never per fragment.
  • Errors after success. Once the 200 status and the headers are sent, the status can’t change, so a failure mid-stream arrives as an event: Anthropic’s error event, an OpenAI chunk with an error field.
  • Partial markdown. The text arrives with unclosed code fences and half-written tables; rendering the whole answer again on every token is slow, and rendering it naively flickers.
  • Flushing in the framework. FastAPI’s StreamingResponse sends each yielded piece; a hand-written server should also disable Nagle’s algorithm (TCP_NODELAY, which asyncio sets), or small events can wait tens of milliseconds for company.

Where this leads

Takeaways

  • Streaming doesn’t make generation faster. It moves the wait the reader feels from the whole answer (4.7 s here for 100 tokens) to the time to first token (0.13 s).
  • Readers take in about 5.5 tokens a second; generation runs several times faster, so with streaming the reader never waits after the first token, as long as time per output token stays under about 180 ms.
  • Model APIs stream with server-sent events: one long HTTP response, a data: line per event, a blank line between. OpenAI’s Chat Completions end with [DONE]; Anthropic, and OpenAI’s Responses API, name every event. WebSockets add a channel back to the server.
  • Send text, not tokens: decode incrementally, or characters split across tokens arrive broken.
  • Stop generating when the user leaves, or a closed tab keeps the GPU and the KV cache busy: 90 of 100 tokens here. Use the transport’s disconnect signal, plus a timeout or heartbeat, since no single signal works through proxies, over HTTP/2 and for vanished clients.
  • A buffering hop turns a stream back into one late response. Test the time to the first event through the whole path, and turn off buffering, compression and short idle timeouts for streams.

References

Start with the WHATWG section on server-sent events, which is short, and Anthropic’s streaming documentation for a real event schema. RFC 6455 §5 is the WebSocket framing written out.


Written by

Tan Le

AI engineer. I write these notes to learn LLM internals and AI engineering in depth, and to keep what I learn where I can find it again.