Home / AI Architecture & System Design / Agents & LLM Applications

Agents & LLM ApplicationsMidQuestion 6 of 9

How would you stream an LLM response to a web client?

30-second answer

Call the provider's streaming endpoint (server-sent events) from the backend, never exposing API keys to the browser. Forward text deltas to the client via SSE or WebSockets, flush promptly, handle client disconnects by cancelling the upstream request, buffer tool-call arguments until complete, and record usage and errors from the final events.

Try answering out loud before reading on. The walkthrough below is paced for a 40-minute interview round.

0–5 min Functional requirements

Streaming looks like a front-end detail, but it touches authentication, cost, proxies, moderation and reliability. Pin down the product first.

You What's being streamed, and to which clients?

Interviewer A chat product on web and mobile. Responses stream token by token, sometimes with tool calls in between (searching docs).

You Do we moderate output before it's shown?

Interviewer Yes, there's an output moderation check.

You What happens if a user closes the tab mid-answer, or their connection drops?

Interviewer Today we keep generating, which wastes money. On mobile, connections drop a lot.

# Functional requirement Why it matters
F1 Stream text to the client as it's generated Perceived latency
F2 Show progress during tool calls ("Searching docs…") Long silent gaps feel broken
F3 Keep provider API keys on the server Keys in clients get stolen
F4 Let users stop generation; cancel upstream work when they do Saves cost; user control
F5 Resume after a dropped connection without regenerating Mobile reliability
F6 Apply output moderation without breaking the streaming experience Safety
F7 Record usage, latency and errors per response Cost attribution and debugging

5–10 min Non-functional requirements and estimates

Requirement Target What it forces in the design
Time to first text ≤ 1 s p95 added by our stack on top of the model's TTFT No buffering anywhere on the path
Smoothness No visible stalls > 500 ms once text starts (except tool calls) Flush per event; avoid proxy buffering
Concurrency 50,000 concurrent streams at peak Async I/O servers; connection limits sized
Cancellation Upstream generation stops ≤ 1 s after the user stops Propagate disconnects/aborts
Resume Reconnect within 60 s gets the rest of the message Server-side buffer per message

Back-of-envelope estimates:

  • Concurrency: 5,000 new responses/s × ~10 s average stream = ~50,000 open streams at peak. Each holds a client connection and an upstream provider connection, so plan file descriptors, memory and load-balancer limits for ~100,000 connections.
  • Bandwidth: ~50 tokens/s × ~4 bytes plus SSE framing (~40 bytes per event if every token is its own event) ≈ 2–3 KB/s per stream → ~150 MB/s total. Coalescing tokens into ~50 ms batches cuts the framing overhead a lot.
  • Wasted generation: if 10% of users abandon mid-answer with ~300 tokens left, at 5,000 responses/s that's ~150,000 wasted output tokens/s without cancellation.

10–18 min High-level design

You The browser talks only to our backend over Server-Sent Events. The backend relays provider events as our own small event schema, keeps a buffer per message for resume, and cancels the provider request when the client goes away.

Scroll sideways to see the whole diagram.

Streaming LLM responses to the browserComponents: Secrets manager (provider API key); Browser (fetch + ReadableStream); Reverse proxy (buffering off); Your backend (auth · quotas · relay); LLM provider (streaming API); Usage log (tokens per user). Flow: Browser to Reverse proxy; Reverse proxy to Browser; Reverse proxy to Your backend; Your backend to Reverse proxy; Your backend to LLM provider (stream request); LLM provider to Your backend (SSE events); Secrets manager to Your backend (key); Your backend to Usage log (usage).Secrets managerprovider API keyBrowserfetch + ReadableStreamReverse proxybuffering offYour backendauth · quotas · relayLLM providerstreaming APIUsage logtokens per userstream requestSSE eventskeyusagePOST /chat →← SSE: delta · done · errorClient abort → cancel upstream
Streaming LLM responses to the browser. The browser never holds the provider key. The backend relays provider events as its own SSE events, the proxy must not buffer, and a client disconnect cancels the upstream request.

Components:

  • Client: fetch with a ReadableStream (supports POST bodies and AbortController), a tolerant incremental Markdown renderer, and a stop button.
  • Edge / reverse proxy: TLS, routing; buffering and compression disabled for the streaming route; idle timeouts longer than the longest silent period (tool calls).
  • Streaming backend: authenticates, applies quotas, calls the provider's streaming API, maps events, coalesces tokens, writes each chunk to a short-lived message buffer, and forwards to the client.
  • Message buffer: per-message log of chunks with sequence numbers in a fast store (e.g. Redis), kept a few minutes for resume.
  • Moderation: checks windows of text before release (see deep dive).
  • Usage log: tokens, cost, stop reason, latency per message.

Event schema (ours, not the provider's):

event: delta        data: {"seq": 41, "text": "The refund window is "}
event: tool_status  data: {"seq": 42, "tool": "search_docs", "state": "running"}
event: delta        data: {"seq": 43, "text": "30 days for annual plans."}
event: done         data: {"seq": 44, "stop_reason": "end_turn", "message_id": "m_123"}
event: error        data: {"seq": 45, "code": "upstream_unavailable", "retryable": true}

Our own schema means clients don't break when we change providers, and sequence numbers make resume possible.

Scroll sideways to see the whole diagram.

Streaming, resume and cancelBrowser → Backend: POST /chat; Backend → LLM provider: stream request (server key); LLM provider → Backend: token events; Backend → Chunk buffer: append chunks 1…41; Backend → Browser: SSE deltas, coalesced ~50 ms; [connection drops]; Browser: reconnect, Last-Event-ID 41; Browser → Backend: resume after 41; Chunk buffer → Backend: chunks 42…57; Backend → Browser: replay, then live; [user presses Stop]; Browser → Backend: cancel; Backend → LLM provider: close stream (stop billing); Backend → Chunk buffer: save partial, mark cancelled.Browserfetch streamBackendrelayChunk bufferby sequenceLLM providerPOST /chatstream request (server key)token eventsappend chunks 1…41SSE deltas, coalesced ~50 msCONNECTION DROPSreconnect, Last-Event-ID 41resume after 41chunks 42…57replay, then liveUSER PRESSES STOPcancelclose stream (stop billing)save partial, mark cancelled
Streaming, resume and cancel. The backend streams from the provider with a server-side key, appends each chunk to a buffer with a sequence number and forwards coalesced deltas. After a dropped connection, the client resumes from its last sequence number. Stop cancels the upstream request so tokens stop being billed.

18–32 min Deep dive

You I'd cover the buffering and timeout pitfalls, cancellation and resume, and how moderation fits with streaming.

Interviewer Users report answers arriving in big bursts after long pauses. Why?

Deep dive 1 · Buffering and timeouts

Almost always, something between the provider and the browser is buffering:

Layer Problem Fix
Reverse proxy (e.g. nginx) Buffers responses until a size threshold Disable buffering for the route (X-Accel-Buffering: no or config)
Compression middleware gzip waits to fill blocks Disable compression for text/event-stream
App framework Response not flushed per event Use a streaming response and flush after each event
CDN / load balancer Buffers or times out idle connections Pass-through mode; idle timeout above the longest tool call; send heartbeat comments every ~15 s
Client Waits for full body (e.g. await res.json()) Read the stream incrementally

Also send a heartbeat (: ping) during long tool calls so intermediaries don't close the connection as idle.

Interviewer How does cancellation work end to end?

Deep dive 2 · Cancellation and resume

  • User presses Stop: the client aborts the fetch. The backend detects the closed connection (or receives an explicit POST /messages/{id}/cancel for reliability) and closes the upstream stream, which stops generation and billing for further tokens. The partial message is saved with stop_reason: cancelled.
  • Connection drops (not a stop): keep generating for a short grace period while buffering chunks. The client reconnects with Last-Event-ID: 43 (or ?after_seq=43), and the backend replays chunks 44+ from the buffer, then continues live.
  • Distinguishing the two: an explicit cancel means stop; a silent disconnect means wait up to ~60 s for a reconnect, then cancel upstream.
  • Server restarts: if the backend instance dies, the client reconnects to another instance; with chunks in a shared buffer it can serve what was generated, then mark the message incomplete and offer "continue".

Deep dive 3 · Moderation with streaming

  • Hold and release by sentence: buffer until a sentence ends (or ~40 tokens), check it, then release. Adds a small delay per sentence but nothing unchecked is shown.
  • Release and retract: for low-risk products, release immediately and send a retract event replacing text if a later check fails. Faster, but users may briefly see the text.
  • Halt: on a violation, cancel upstream, emit a replacement message, and mark the turn so the partial text isn't fed back into the conversation.

Deep dive 4 · Rendering (if time allows)

  • Incremental Markdown: partially received Markdown is often invalid (an open code fence). Use a renderer that tolerates incomplete input, or re-render the last block on each chunk.
  • Coalescing: render at most every ~30–50 ms to avoid layout thrash on fast streams.
  • Accessibility: announce completion to screen readers rather than every token.

32–37 min Trade-offs

Decision Chosen Alternative Why
Transport SSE over fetch WebSockets One-way, proxy-friendly, simple; WebSockets suit bidirectional sessions like voice
Event format Our own schema with sequence numbers Pass provider events through Provider independence; resume
Resume Server-side chunk buffer Regenerate on reconnect No double cost; consistent answers
Disconnects Grace period, then cancel Cancel immediately / never Balances mobile drops against waste
Token events Coalesced ~50 ms batches One event per token Less overhead; still smooth
Moderation Hold per sentence (by default) Release then retract Nothing unchecked shown; small delay

Build first: the backend relay with our event schema, proxy settings and heartbeats, and cancellation. Then the resume buffer, then moderation windows.

37–40 min Wrap-up and likely follow-ups

Close with: "Clients stream from our backend over SSE using our own event schema with sequence numbers; the backend calls the provider with server-side keys, flushes per coalesced event, sends heartbeats during tool calls, and disables buffering on every hop. A per-message chunk buffer supports resume, explicit stops cancel upstream immediately, and silent disconnects get a short grace period. Moderation holds text per sentence before release."

Likely follow-ups:

  • How do you load-balance long-lived connections? Least-connections balancing, connection limits per instance, and draining (stop accepting new streams, let existing ones finish) during deploys.
  • What about serverless platforms? Check response-duration and streaming support limits; long agent tasks may need a separate long-running service.
  • Mobile backgrounding? Rely on resume: when the app returns, reconnect with the last sequence number.

What interviewers listen for

  • Keys server-side and a relay design with its own event format.
  • Buffering, compression and idle-timeout pitfalls, with heartbeats.
  • Cancellation that actually stops upstream generation, and a resume strategy.
  • How moderation interacts with streaming.

Red flags

  • Calling the provider directly from the browser with an API key.
  • Ignoring client disconnects (paying for abandoned generations).
  • "Just use WebSockets" without saying why.