A handler returns a Web Response, so three constructors cover everything: Response.json(data, init) for JSON, new Response(body, init) for other content, and new Response(null, { status }) for an empty answer. Response.json sets Content-Type: application/json for you; the plain constructor sets nothing, so a folder named app/rss.xml/ returning a feed must pass 'Content-Type': 'application/xml' itself.
Status codes are worth getting right, because clients branch on them: 201 with a Location header for a created resource, 204 with a null body for a successful delete, 422 for a body that parsed but failed validation, 409 for a conflict such as a duplicate email, 400 only for unparseable input.
Because the body of a Response can be a ReadableStream, a handler can send bytes as it produces them instead of buffering the whole answer — what an AI chat endpoint does, and equally useful for long exports. The pattern is to pull from an async generator:
const encoder = new TextEncoder()
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function* ticks() {
for (const sym of ['ACME', 'GLOBEX', 'INITECH']) {
yield encoder.encode(`data: ${JSON.stringify({ sym, at: 'tick' })}\n\n`)
await sleep(300)
}
}
export async function GET() {
const iterator = ticks()
const stream = new ReadableStream({
async pull(controller) {
const { value, done } = await iterator.next()
if (done) controller.close()
else controller.enqueue(value)
},
})
const headers = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' }
return new Response(stream, { headers })
}pull is called whenever the consumer is ready for more, which gives you backpressure for free: a slow client slows the generator instead of filling a buffer. Timestamping each line on arrival shows the chunks landing 300 ms apart rather than all at once at the end:
$ curl -sN http://localhost:3000/api/prices \
| while read -r l; do echo "$(date +%S.%3N) $l"; done
02.707 data: {"sym":"ACME","at":"tick"}
02.960 data: {"sym":"GLOBEX","at":"tick"}
03.271 data: {"sym":"INITECH","at":"tick"}Three details make or break a stream in production. Cache-Control: no-store stops a CDN buffering the whole response and defeating the point. text/event-stream lets the browser's EventSource consume it directly; use application/x-ndjson if you read it with fetch and a reader. And a reverse proxy may hold chunks back: nginx 75 needs proxy_buffering off (Self-Hosting Behind nginx). A fourth detail is the client — curl 3,008 buffers unless you pass -N, so check it before you blame the handler.