A demo chat streams tokens; a production chat engineers the stream like state. That means batching tokens so 60 renders a second do not melt low-end phones, cancellation that actually reaches the provider, streams that survive a refresh, and an explicit contract for what the UI shows when generation dies halfway.
Tier: Advanced · Workflow area: AI features in the client
This playbook hardens the surface built in pb-47 against the four ways streams hurt in production: render thrash, zombie generations, lost streams and ambiguous partial failure.
Skip resumability if generations are short and cheap to redo — batching and cancellation are still mandatory.
useChat (or equivalent) against your own SSE endpoint, part-based rendering.Batch token application, don't render per chunk. Provider chunks can arrive at hundreds per second; applying each to state re-renders the transcript per token. Two levers, use both:
streamText supports chunk smoothing (experimental_transform: smoothStream()) to emit steadier, word-ish chunks.useChat accepts a throttle for UI updates (experimental_throttle: 50) so state applies at ~20fps regardless of chunk rate.Then stop the collateral re-renders: memoize each completed message component (React.memo keyed on message id + part count) so only the streaming message updates, and memoize markdown parsing per part — a naive implementation re-parses the entire transcript on every token.
Make cancellation reach the provider, not just the UI. stop() aborting the client fetch is half the job; the server must propagate:
export async function POST(req: Request) {
const result = streamText({
model, messages,
abortSignal: req.signal, // provider call dies with the request
});
return result.toUIMessageStreamResponse();
}
Forward req.signal into the provider call so an abandoned tab or stop click stops token billing. Handle the abort on both sides: client marks the message as stopped-partial (kept, labeled), server's onAbort/onFinish persists the partial turn so refresh agrees with what the user saw.
Decide the partial-failure contract explicitly. Streams have a nasty property: HTTP 200 is already sent when the provider errors mid-generation, so failure arrives inside the stream, not as a status code. Write the contract down and implement it:
Add resumable streams for the flows that earn them. For long generations, wire the SDK's resumption path: the route buffers the stream under the chat id (Redis-backed resumable-stream), and the client, on mount, checks for an active stream for the conversation and re-attaches (resume: true in useChat). A refresh mid-generation then rejoins the live stream instead of orphaning it. Scope this to long-running surfaces — it adds infra (a store, stream GC) that short chats do not justify.
Test the failure matrix, not the happy path. Simulate each: throttle CPU 6x and stream a 2,000-token reply (jank check); kill the network mid-stream (error-part rendering); refresh mid-generation (resume or clean partial); click stop and immediately regenerate (idempotency); background the tab for a minute mid-stream on mobile Safari (SSE suspension — verify reconnect or clean failure). Each scenario has a defined expected outcome from step 3's contract; anything undefined is a bug in the contract, not the code.
Treat the stream as distributed state with an explicit lifecycle — batched application, propagated aborts, in-band errors, durable partials, optional resumption — and write the partial-failure contract before the incident that would have forced you to.
fetch + ReadableStream: you own chunk parsing, an rAF/interval batching buffer, AbortController wiring end-to-end, and reconnection; budget days, not hours, and steal the SDK's wire format.req.signal to the provider call.All techniques here are provider-neutral and run against your own endpoint, so in-tenancy providers (Bedrock, Azure) change nothing client-side. One caveat: some corporate proxies and gateways buffer SSE responses, which silently defeats streaming — verify chunked delivery through the real network path early, and fall back to a "generate then render" non-streaming mode where the infrastructure cannot pass streams through.
A proxy that buffers SSE turns your streaming UI into a long spinner followed by a wall of text — and your resilience states never fire. Test through production infrastructure, not localhost, before declaring this done.