Streaming
Why an LLM token stream or a live progress log needs a different response shape than JSON, and how ctx.stream()/ctx.sse()/ctx.ndjson() send many chunks over one open connection.
An LLM does not return an answer — it returns tokens, one at a time, over several seconds. Wait for ctx.json() to have the whole thing before sending anything, and a user watches a blank screen for the entire generation instead of watching words arrive as the model produces them.
What you'll learn
- Understand why a normal request/response cannot represent a value that arrives over time
- Recognize the three protocol-specific writers —
ctx.stream(),ctx.sse(),ctx.ndjson()— and when each applies - Understand how the writer callback keeps the connection open until it resolves, then closes it automatically
- Choose when a route needs streaming instead of a single JSON response
The problem
ctx.json(data) assumes data exists, in full, at the moment you call it. That assumption holds for a database read; it does not hold for a token-by-token model completion, a multi-step agent trace, or a long export job reporting progress. Trying to force that shape onto response streaming means buffering everything server-side first:
import { createRouter } from 'nextrush';
declare function generateFullReply(prompt: string): Promise<string>;
const router = createRouter();
router.post('/chat', async (ctx) => {
const reply = await generateFullReply(ctx.body as string); // blocks until the model is done
ctx.json({ reply }); // client sees nothing until now
});The handler is correct and the response is valid JSON — but the client's connection sits idle for the full generation time, and if the model takes thirty seconds, the user has no signal the request is even progressing.
Why this matters
A chat UI, an agent trace viewer, or a CSV export all share the same shape: work that produces output incrementally, where showing each piece as it's ready is the point. Buffer that work into one response and the delay isn't limited to the first byte — the incremental signal is thrown away entirely, so a client that could render partial output instead renders nothing until the very end. The fix is not a bigger buffer; it's a response that can be written to more than once.
The solution
@nextrush/stream gives every adapter three protocol-specific entry points on ctx: stream() for raw text or bytes, sse() for Server-Sent Events, and ndjson() for newline-delimited JSON. Each takes one callback, hands it a writer built for that wire format, and keeps the connection open until the callback resolves — write as many chunks as the work produces, then the response closes on its own.
Core idea
A normal handler answers a question once. A streaming handler keeps the same connection open and answers it in installments — the client sees each chunk as soon as the writer sends it, instead of waiting for one final value. The writer, not the handler, owns how each chunk is framed on the wire; the handler's only job is to call write() (or hand off an existing async source with consume()) until there's nothing left to send.
Mental model
Notice the loop sends several messages over one still-open connection, and the alt shows the abort path is a real, equally-expected outcome alongside the completion path — a streaming response has two ways to end, not one.
Quick example
The smallest streaming handler writes a few chunks and lets the connection close on its own:
import { createApp, listen } from 'nextrush';
const app = createApp();
app.get('/progress', async (ctx) => {
await ctx.stream(async (writer) => {
await writer.write('Loading...\n');
await writer.write('Processing...\n');
await writer.write('Done.\n');
});
});
listen(app, 8080);No content type to set, no manual flush — ctx.stream() sets Content-Type: text/plain; charset=utf-8 and closes the response once the callback returns.
How it works
Example — stream an LLM completion as Server-Sent Events, and cancel the upstream call the instant the client disconnects:
import { createApp, listen } from 'nextrush';
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
declare const openai: {
chat: {
completions: {
create(
body: { model: string; messages: ChatMessage[]; stream: true },
options: { signal: AbortSignal },
): Promise<AsyncIterable<{ choices: { delta: { content?: string } }[] }>>;
};
};
};
const app = createApp();
app.post('/chat', async (ctx) => {
await ctx.sse(async (writer) => {
const completion = await openai.chat.completions.create(
{ model: 'gpt-5', messages: ctx.body as ChatMessage[], stream: true },
{ signal: writer.signal }, // aborts OpenAI the moment the client disconnects
);
for await (const chunk of completion) {
const token = chunk.choices[0]?.delta?.content;
if (token) await writer.write({ data: token });
}
});
});
listen(app, 8080);Observation — the client starts receiving data: lines as soon as the model produces the first token, not after the full reply is assembled. If the browser tab closes mid-generation, writer.signal fires and the create() call above receives the abort — the upstream request stops instead of running to completion for tokens nobody will read.
Explanation — three things make this work:
ctx.sse(run)setsContent-Type: text/event-streamandCache-Control: no-cache, builds anSSEStreamWriter, and callsrun(writer). The connection stays open for as long asrunis pending.writer.write({ data, event?, id?, retry? })formats one event to the SSE wire spec — multi-linedatais split into onedata:line per line, andevent/idvalues have carriage returns and newlines stripped so a value can't inject an extra field or event.writer.signalis anAbortSignalthat fires the moment the client disconnects. Passing it into the model call's ownsignaloption is what turns "the response stopped" into "the upstream generation stopped" — without it, a disconnected client still leaves the completion running server-side.
Writing after the client has disconnected throws StreamAbortedError rather than dropping the chunk silently; ctx.sse() catches it at the boundary and closes cleanly, so a handler that ignores cancellation still cannot corrupt a response — see errors for how NextRush's error hierarchy treats this as a controlled shutdown, not a failure. The full internal design — StreamController, backpressure, and how one code path serves Node's eager pump and Bun/Deno/edge's lazy Response body identically — lives in @nextrush/stream's architecture documentation.
Typical use cases
Reach for a streaming writer when the response is inherently incremental — not as a general substitute for ctx.json():
ctx.stream()— plain progress text, log tails, CSV export rows: any chunked output with no framing requirements of its own.ctx.sse()— LLM chat completions, live notifications, anything a browser'sEventSource(or a hand-rolled SSE client) will consume with automatic reconnection semantics.ctx.ndjson()— structured agent traces, tool-call logs, server-to-server pipelines where each line is its own JSON value and no browser-specific framing is needed.- An existing async source — an AI SDK's
textStream, a database cursor, or a NodeReadablecan be handed towriter.consume(source)directly; it normalizesAsyncIterableand WebReadableStreamsources to the same internal iteration path, so the handler never branches on which shape it received.
Performance
- Complexity — each
write()call does constant-time formatting and one enqueue; total cost scales with the number of chunks written, not with response size held in memory at once. - Memory — nothing buffers the full response server-side. A ten-thousand-token completion holds, at most, the current chunk in memory — the opposite of
ctx.json(), which must hold the entire payload before it can send the first byte.
Cooperative backpressure means a slow client (or a slow network) pauses write() until the consumer is ready for more — the handler never outruns what the connection can actually deliver.
- Scaling — a long-lived streaming response holds a connection for its full duration, so a route that streams for minutes at a time consumes a connection slot for minutes, unlike a request/response route that frees its slot in milliseconds. Measure real hold times with
apps/benchmarkbefore sizing a connection pool around streaming traffic.
Security
- Abandoned upstream calls. Threat: a client disconnects mid-generation, but the LLM (or any other paid upstream call) keeps running because nothing told it to stop — a direct cost leak. Why: without wiring
writer.signalinto the upstream SDK's own abort option, the writer knows the client is gone but the upstream call doesn't. Safe default: pass{ signal: writer.signal }(or the SDK's equivalent option) into every long-running call made inside a streaming handler. Avoid: starting an upstream call with no abort wiring and assuming the connection closing is enough. - SSE field injection. Threat: an
eventoridvalue containing a newline could inject an extra SSE field or a second event into the wire format. Why: the SSE spec treats a bare\nas a field terminator. Safe default: this is handled automatically —writer.write()strips carriage returns and newlines fromevent/idbefore framing. Avoid: bypassing the writer to hand-format SSE text directly. - Silent data loss after disconnect. Threat: a handler that keeps writing after the client is gone could silently drop chunks with no error surfaced anywhere. Why: a network write to a closed connection has no return value that says "nobody received this." Safe default: NextRush throws
StreamAbortedErroron write-after-abort instead of no-op'ing, so a handler that ignores cancellation still fails loudly rather than producing a response that quietly stops mid-stream. Avoid: catchingStreamAbortedErrorbroadly and continuing to write — the connection really is gone.
A streaming handler that starts an upstream LLM or database call without passing writer.signal into it leaves that call running, and being billed for, after every client has already left.
Trade-offs
Why three protocol-specific writers instead of one generic streaming API — each format optimizes for a different consumer, and picking the wrong one costs a rewrite once a real client shows up.
- Benefits —
ctx.sse()gets automatic reconnection semantics for free from anyEventSource-based client;ctx.ndjson()needs no browser-specific framing for server-to-server pipelines;ctx.stream()covers raw text with no protocol overhead at all. Choosing the writer up front means the handler never reformats output later to fit a client it didn't anticipate. - Costs — three writer types to learn instead of one, and a handler written for
ctx.stream()doesn't carry over toctx.sse()without a rewrite of how each chunk is shaped. - Alternatives — WebSockets give a bidirectional channel, at the cost of a connection-management model neither
fetchnorEventSourceneed; polling avoids a long-lived connection entirely, at the cost of latency between polls and repeated request overhead for output that's naturally continuous. - Why NextRush chose this — response streaming here is strictly server-to-client; a use case needing the client to send messages back over the same connection belongs on
@nextrush/websocketinstead of stretching one of these three writers to do a job they weren't built for.
Decision guide
Choose ctx.sse() when:
- ✓ The client is (or could be) a browser using
EventSource, or wants automatic reconnection - ✓ The output is a token stream, a notification feed, or anything naturally event-shaped
Choose ctx.ndjson() when:
- ✓ The consumer is a server, a CLI, or another backend service, not a browser
- ✓ Each unit of output is a self-contained JSON value with no need for SSE's
event/id/retryfields
Choose ctx.stream() when:
- ✓ The payload is plain text or raw bytes with no wire-format framing to apply
Avoid all three when:
- ✗ The full response is available immediately and cheaply —
ctx.json()is the correct tool for a value that already exists in full
Common mistakes
- Starting an upstream call without
{ signal: writer.signal }. Why it happens: the streaming response works during testing, when nobody disconnects early, so the missing abort wiring never surfaces. Correct approach: passwriter.signal(or the SDK's equivalent abort option) into every upstream call made inside the writer callback. If ignored: a disconnected client leaves the upstream call running and billing for output nobody will ever read. - Reaching for
ctx.stream()when the client is a browser expectingEventSource. Why it happens:ctx.stream()is the simplest of the three and looks interchangeable withctx.sse()for plain text. Correct approach: usectx.sse()whenever the consumer relies on SSE framing or reconnection —ctx.stream()sends noevent:/id:fields at all. If ignored: anEventSource-based client never fires itsonmessagehandler, because the bytes arriving aren't valid SSE. - Catching
StreamAbortedErrorand continuing to write. Why it happens: it looks like any other recoverable error, so a broadcatchswallows it and the loop continues. Correct approach: let it propagate to the writer boundary (or catch it only to run cleanup, then stop writing) — the client is genuinely gone. If ignored: every subsequentwrite()throws the same error again, and any resources allocated for the retried writes are wasted.
Key takeaways
ctx.stream()/ctx.sse()/ctx.ndjson()all follow the same shape: pass a callback, receive a protocol-specific writer, write until done — the connection closes automatically when the callback resolves.- Response streaming differs from a normal request/response by keeping one connection open across several writes, instead of one value sent once.
writer.signalis anAbortSignalthat fires on client disconnect — wire it into any upstream SDK call's own abort option, or a disconnected client leaves that call running server-side.- Writing after disconnect throws
StreamAbortedError, caught and closed cleanly at thectx.stream()/ctx.sse()/ctx.ndjson()boundary — a handler that ignores cancellation still cannot produce a corrupted response. writer.consume(source)adapts an existingAsyncIterableor WebReadableStream— an AI SDK's token stream, a database cursor — to the writer'swrite()calls in one call, with no manual loop.- The public writer API is identical on Node, Bun, Deno, and edge; only the internal transport (
ctx.sendStream()) differs per adapter, and application code never touches it directly.
Continue learning
Errors
How StreamAbortedError fits NextRush's error hierarchy as a controlled shutdown, not a failure.
Runtime compatibility
Why the same streaming handler runs unmodified on Node, Bun, Deno, and edge adapters.
@nextrush/stream reference
The full writer API — StreamController, StreamAbortedError, formatSSE, and the low-level run functions.
Extensions
How a long-lived service like an event bus differs from a per-request writer.