An LLM call that takes 3.4 seconds end to end doesn't feel like 3.4 seconds if the user sees words appearing after 400 milliseconds. It feels instant, then fast. The same call rendered as a blank screen for 3.4 seconds, followed by a full paragraph appearing all at once, feels broken, then slow, then like the product hung. Same backend, same model, same dollar cost. Different perceived speed, entirely because of how the response was delivered.
Most teams treat latency as one number to shrink: total response time. That's the wrong target. The number that determines whether a feature feels fast is time to first token (TTFT), and the number that determines whether it keeps feeling fast is token throughput after that. Get those two right and a six-second response reads as fast. Get them wrong and a 1.5-second response reads as sluggish, because it's rendered as one blocking paint that the user has no evidence is coming.
We've shipped AI features across a POS platform that has to work offline, a construction estimator running vision models against forty-page plan sets, and a document-heavy retrieval pipeline serving in-app chat. In none of those did users complain about raw model latency. They complained about the absence of any signal that work was happening.
Time to First Token Is the Metric, Not Total Latency
Humans start judging responsiveness in the first few hundred milliseconds. Past roughly 400-500ms with zero visible change, a request starts registering as "stuck" rather than "processing." That threshold doesn't move because the underlying model is slower or faster; it's a property of attention, not compute. So the decision rule is simple: if your TTFT is reliably under 400-500ms, a plain loading spinner is tolerable. If it isn't, and for most LLM calls involving retrieval or tool use it won't be, you need a visible state change before the first token arrives, and real tokens streaming as soon as the model produces them.
This is why optimizing total latency in isolation is a trap. A team that shaves total response time from 6 seconds to 4.5 seconds but still renders nothing until the response is complete has improved a number nobody experiences. A team that gets TTFT from 2.8 seconds to 600 milliseconds, even if total time stays at 6 seconds, has changed what the product feels like to use.
What Streaming Actually Buys You (and What It Doesn't)
Streaming doesn't reduce the amount of compute a request needs. It doesn't make retrieval faster or a tool call return sooner. What it does is move the moment of first feedback earlier and spread the remaining latency across a visible, incrementing signal instead of a silent gap. That's the entire mechanism, and it's worth being precise about it because teams sometimes ship streaming and are surprised total cost or total time didn't improve. It wasn't supposed to.
The pieces that happen before the model starts generating, retrieval, reranking, prompt assembly, don't benefit from streaming at all, because there's nothing to stream yet. If your chunking and retrieval design adds 1.5 seconds before the model even sees a prompt, streaming the eventual answer doesn't fix that gap; it just means you now need a separate signal, like "searching your documents," to cover it.
Where the Time Actually Goes
Before deciding what to stream, break the request into its stages and time each one separately. A typical retrieval-augmented feature has five:
- Retrieval — vector search, filtering, reranking against your document store
- Prompt assembly — formatting retrieved chunks, history and instructions into the request
- Model inference (time to first token) — the model's own queueing and startup cost
- Model inference (throughput) — tokens per second once generation starts
- Tool calls or post-processing — anything the model triggers mid-response, plus validation before you show the result
Most teams can name their total latency but not the split across these five. That's a problem, because the fix for a 3-second retrieval stage is completely different from the fix for a 3-second generation stage, and streaming only ever addresses the last two. If retrieval is your bottleneck, no amount of token streaming hides it; you need a loading state that covers that specific gap, or you need to make retrieval faster.
Streaming Structured Output Without Breaking the Parser
Streaming free-text chat is straightforward. Streaming structured output, JSON that your UI needs to parse and render incrementally, is where most implementations break, because a partial JSON object isn't valid JSON. You either buffer until the object closes (losing the perceived-speed benefit for that field) or you parse incrementally against a schema that tolerates partial state.
A minimal server-sent-events handler for a streaming completion looks like this:
export default {
async fetch(req: Request) {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();
(async () => {
for await (const chunk of streamCompletion(req)) {
await writer.write(encoder.encode(`data: ${chunk}\n\n`));
}
await writer.close();
})();
return new Response(readable, {
headers: { "Content-Type": "text/event-stream" },
});
},
};
On the client, the practical pattern is to stream at the field level for anything the user reads as prose (a summary, an explanation) and buffer at the object level for anything the user or downstream code needs whole before it's actionable. A takeoff line item with a confidence score attached only means something once the quantity, the unit and the score all exist together; streaming a half-formed row with no score yet is worse than a one-second wait, because it invites the user to act on incomplete data. We've seen this directly in vision-model extraction from raw plans, where partial output before confidence scoring completes is actively misleading rather than merely early.
Failure Modes Unique to a Stream
A blocking request either succeeds or fails cleanly. A stream can fail halfway through, after the user has already read three paragraphs and started acting on them. That changes what you need to handle:
- Detect and surface mid-stream errors distinctly from a clean completion, don't let a truncated response render as a finished one
- Respect the client's abort signal and actually cancel the upstream model call, not just stop rendering
- Decide, in advance, whether a user gets billed for tokens generated before they cancelled, and make that decision consistent with how your AI spend wallets and per-tenant budgets account for partial completions
- Give the user a visible cancel control once a response starts streaming, not just before it starts
- Cap max output tokens server-side so a runaway generation doesn't tie up a connection indefinitely
The billing question is easy to skip until a support ticket forces it. If a tenant's budget enforcement only checks spend at request completion, a user who starts ten requests and cancels nine of them mid-stream can burn real tokens that never show up in the budget check until it's too late.
When Streaming Doesn't Help
Streaming adds real complexity: connection management, partial-state handling, cancellation, and a client that has to render incomplete data gracefully. It's worth it when the response is long enough or slow enough that a user would otherwise wait and watch nothing happen. It is not worth it for short, cached, or near-instant responses, adding a streaming pipeline to a call that already returns in 300ms is pure overhead for no perceptible gain.
It also isn't the right answer for agentic tool-call traces. Streaming a model's raw intermediate reasoning or tool invocations to the end user looks like transparency, but it exposes internal prompts and tool arguments that may include content pulled from untrusted documents. That's the same surface we've written about in the context of prompt injection hiding inside customer documents, and it's a reason to stream a summarized progress state, "searching invoices," "checking inventory", rather than the literal tool-call payload.
A spinner tells the user nothing changed. A stream tells them work is happening.
If you're deciding how much of this to build versus how much to defer, that's the kind of tradeoff worth scoping before the first sprint rather than discovering mid-build. It's the sort of production detail we account for in how we build AI features, because it's cheap to fix at design time and expensive to retrofit once users have already learned to distrust the loading state.
Frequently asked
What is time to first token and why does it matter more than total latency?
Time to first token (TTFT) is how long a user waits before seeing any output at all. It matters more than total latency because perceived speed is set in the first few hundred milliseconds; a slow total response that starts streaming quickly feels faster than a fast total response with no visible feedback until it finishes.
Does streaming actually make an LLM feature faster?
No. Streaming doesn't reduce compute time, retrieval time, or total tokens generated. It changes when the user sees the first evidence of progress and spreads the rest of the wait across a visible signal instead of a blank screen. Total latency stays the same; perceived latency drops.
How do you stream structured JSON without breaking the client parser?
Stream field-by-field for anything rendered as prose, and buffer at the object level for anything that needs to be complete before it's usable, like a confidence-scored data row. Parsing raw partial JSON against a strict schema will throw; either use an incremental parser built for partial state or wait for a closing delimiter before validating.
Why does a stream sometimes feel slow even with fast time to first token?
If throughput after the first token is low, the user watches text trickle in at an unnatural pace, which reads as sluggish even though it started fast. TTFT gets the user's attention; sustained tokens-per-second keeps it. Both need to be measured, not just the first one.
Should agentic tool calls be streamed to the end user?
Generally no. Raw tool-call traces can leak internal prompts or content pulled from untrusted documents, and they rarely mean anything to a non-technical user. Stream a summarized progress state instead, such as which action is in progress, and keep the raw trace in logs for debugging.