Why your Next.js SSE stream arrives as one blob (and the settings that fix it)
Your tokens stream server-side but reach the browser all at once. The headers, proxy buffering, and timeouts that make SSE actually stream in Next.js.
Your route logs tokens flowing out one at a time. The browser shows nothing — then the entire answer appears at once, as if it were never a stream at all. The model is streaming fine; something between your route handler and the browser is holding the bytes back and releasing them in a single flush. It is almost always one of three things, and none of them show up on localhost.
The response headers that stop a proxy from buffering
An SSE response has to tell every hop between your server and the browser to pass bytes through untouched. The kit's stream helper sets exactly that on the Response it hands back from the route:
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
The one that matters most is no-transform. Proxies and CDNs are allowed to buffer, compress, and otherwise "helpfully" transform a response — which for a stream means collecting it and delivering it as one chunk. no-transform withdraws that permission. If nginx sits in front of you, it needs the same message in its own dialect: the X-Accel-Buffering: no header turns off its proxy buffer for that response specifically.
Raise the route's max duration
The second blob-maker is the platform timeout. A serverless route has a default execution ceiling, and a long generation runs straight past it — the function is killed mid-stream, and the browser gets a truncated response or an abrupt close instead of a finished answer. Set the route's max duration to your longest realistic generation, and pin the Node.js runtime so streaming behaves:
export const runtime = "nodejs";
export const maxDuration = 300;
This is a per-route export in the App Router, and the ceiling you can actually set is capped by your hosting tier — know that number before you promise long streams.
The frame format is part of the contract
Even with perfect headers, the wire format can defeat you. An SSE event is terminated by a blank line, so each frame is data: <payload>\n\n — the double newline is not decoration, it is the delimiter. Emit \n instead of \n\n and the client sits there buffering, waiting for an event boundary that never comes, until the connection closes and it flushes everything at once. That looks exactly like proxy buffering but originates in your own encoder:
const send = (event) =>
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
Things that bite
- It works in dev, breaks in prod. Localhost has no CDN or reverse proxy in the path, so buffering only appears once something sits in front of your app. "Streams fine on my machine" tells you nothing about production.
- Compression middleware re-buffers. A gzip layer added globally will collect the stream to compress it. Exempt the event-stream route, or rely on
no-transformbeing honored end to end. - The client can buffer too. Read the response body as a stream and process each frame as it arrives; a reader that waits for the whole body recreates the blob on the browser side.
- maxDuration is a ceiling, not a reservation. Raising it lets long streams finish; it does not make them faster, and it is still bounded by your plan.
This is the companion to the other half of production streaming — stopping the generation when the user leaves — which I wrote up in aborting the stream on disconnect. Shipwright, the Next.js and Claude starter kit this blog documents, ships both halves wired into one chatStream helper you hand straight back from a route. Watch it stream token by token in the live demo.