Streaming
Answer before the whole body exists: a text export written line by line with streamText(), and a feed of server-sent events with streamSSE() that keeps the connection open.
What you'll learn
- Stream a text body with streamText() and its write(), writeln() and sleep()
- Send server-sent events with streamSSE() and writeSSE({ event, data, id }), and stop when the client goes away
- Understand why an error inside a stream cannot become an error response
Every response so far was built whole and then sent: c.json(cats) serialises the array and hands it over in one piece. Two kinds of route cannot work that way. An export of a large table should start arriving before the last row is read, or the client waits for the whole thing with nothing on screen. And a feed of events, a cat adopted, a sensor reading, a progress update, has no end at all; the response stays open and grows. A Response body is a ReadableStream underneath, so both are possible without leaving the web platform, and Hono's hono/streaming helpers hide the stream plumbing.
Writing a body over time
import { streamText } from 'hono/streaming';
app.get('/report', (c) =>
streamText(c, async (stream) => {
await stream.writeln('line one');
await stream.sleep(500);
await stream.write('line two');
}),
);
streamText(c, callback) returns the response at once, with content-type: text/plain, transfer-encoding: chunked and x-content-type-options: nosniff set by the helper, and runs the callback in the background. Each write() or writeln() sends its text immediately; sleep(ms) waits; when the callback returns, the stream is closed and the response is complete. stream() is the same without any headers, for binary data or piping another stream in with stream.pipe(). What the handler returns is still a Response; the handler is not async here because the helper, not the handler, waits for the callback.
Server-sent events
Server-sent events are a text format a browser's EventSource reads: a response with content-type: text/event-stream whose body is a sequence of messages,
event: adopted
data: {"id":1,"name":"Tom"}
id: 1
each ending in a blank line. streamSSE writes them:
import { streamSSE } from 'hono/streaming';
app.get('/clock', (c) =>
streamSSE(c, async (stream) => {
let id = 0;
while (!stream.aborted) {
await stream.writeSSE({ event: 'tick', data: new Date().toISOString(), id: String(id++) });
await stream.sleep(1000);
}
}),
);
writeSSE({ data, event, id, retry }) takes data as a string, so an object goes through JSON.stringify; event and id are optional. The loop runs until the client disconnects: when it does, stream.aborted becomes true and stream.onAbort(fn) runs whatever was registered. A feed that ignores aborted keeps a callback alive for a client that is gone.
Errors after the first byte
A streaming response has already sent its status and headers when the first write() happens. If the callback throws after that, there is nothing to turn into a 500: app.onError() does not run, the response stays 200, and the client sees a body that stops. The helpers take a third argument for that case, streamText(c, callback, (err, stream) => …), where an error line can be written and the error logged. Whatever must be checked before the stream starts, permissions, that the thing exists, belongs in front of the helper, where a throw is still an error response.
What the grader does with a stream
An export ends, so it is graded like any body once complete. A feed never ends, so the grader reads it for a window after the first event, closes the connection as a browser navigating away would, and grades what arrived; the Endpoints tab marks such a response as partial. The runtime does the same for the request bar.
Your task
The cats API gets two streamed routes:
GET /cats/exportstreams a CSV withstreamText: the lineid,name,age, then one line per cat such as1,Tom,3, with a shortsleepbetween lines.GET /cats/feedstreams events withstreamSSE: for each cat, anadoptedevent whosedatais the cat as JSON and whoseidis the cat's id, 100 ms apart; then apingevent every 100 ms until the client goes away.
Send /cats/feed from the request bar and watch the response panel fill up.
When it fails
GET /cats/exportfails on thex-content-type-optionsheader: the CSV was built as a string and sent withc.text(). The helper sets that header; usestreamText.- The feed's
dataline reads[object Object]: the cat was passed as is.datais a string;JSON.stringifyit. - The feed shows no
ping: the loop after the cats is missing, or it ended on its own. It runs while!stream.aborted. - The console shows an error and the response is a
200that stops early: the callback threw after writing. Errors inside a stream go to the helper's third argument, and checks go before the helper.
Remember
streamText(c, cb)andstream(c, cb)answer at once and write the body from the callback:write,writeln,sleep,pipe.streamSSE(c, cb)writesevent/data/idmessages a browser'sEventSourcereads; loop while!stream.aborted.- After the first byte an error cannot change the status; check first, and use the helper's error callback.
- A feed is graded on what arrives within a window.
Stuck? Show a hint
streamText(c, async (stream) => { … }) and streamSSE(c, async (stream) => { … }) both return the response; the callback writes into it. writeSSE takes { event, data, id }; data must be a string, so JSON.stringify the cat. A loop that must end when the reader leaves checks stream.aborted.