Streams
Some responses are not a value you compute and send. They are a sequence that arrives over time: tokens from a model, lines from a job, events from a queue. Buffering those into one body means the client waits for the last one before it sees the first. When the complete value is already available there is no reason to stream it: an ordinary buffered response is the same latency and none of the disconnect and partial-response handling.
pw.WriteStream[T] opens a response and hands it to a callback:
func events(w http.ResponseWriter, r *http.Request) { pw.WriteStream(w, r, func(stream *pw.Stream[ChatEvent]) error { for event := range source { if err := stream.Write(event); err != nil { return err } } return nil })}WriteStream negotiates the wire format, commits the status and headers, runs
the callback, and closes the stream whichever way the callback ends. Write
sends and flushes one value.
Closing is the runtime’s rather than yours, and that is the point of the shape. The JSON-array format ends in a bracket that has to be written for the document to parse, and a handler that returns early — on an error, on a client that hung up — used to leave it out. Now the bracket is written when the callback returns, however it returns.
The client picks the format
Section titled “The client picks the format”The same handler serves a browser’s EventSource, a curl pipeline, and a
fetch().then(r => r.json()) without knowing which one called it:
| Format | Media type | Framing |
|---|---|---|
| Server-Sent Events | text/event-stream |
data: {…} followed by a blank line |
| NDJSON | application/x-ndjson, application/ndjson, application/jsonl |
one JSON object per line |
| JSON array | application/json |
one […] document, items appended as they arrive |
Selection runs in four steps, stopping at the first that answers:
?stream=— an explicit override.sse,event-stream,events, andeventstreamselect SSE;ndjson,jsonl,nd, andlinesselect NDJSON;json,array,json-array, andjsonarrayselect the JSON array.Accept— the leftmost media type in the header that matches one of the rows above wins.User-Agent— a browser token gets SSE;curl,wget, andhttpieget NDJSON.- NDJSON, as the default, because it is the one a shell pipeline can read a line at a time.
The query parameter exists so you can look at a stream in a browser address bar
without the browser’s own Accept deciding for you.
Each format brings the headers it needs. SSE gets Cache-Control: no-cache,
Connection: keep-alive, and X-Accel-Buffering: no — the last so an nginx in
front of the application stops buffering the response it is supposed to be
forwarding. The JSON formats get Cache-Control: no-cache and their own content
type.
When there is nothing acceptable
Section titled “When there is nothing acceptable”If the request’s Accept header rules out every supported representation, the
stream never starts. It answers 406 Not Acceptable as a
problem response and the callback never
runs. That is the one stream failure that can still be a problem response:
after the stream opens, the status has already gone out, so an error the
callback returns cannot change it. Those reach the handler installed with
pw.SetStreamErrorHandler, which is where a mid-stream failure is logged.
This gate reads Accept alone. Accept: text/html is a 406 even with
?stream=sse attached, because the override chooses among formats a client
said it would take, and that client said it would take none of them.
Long-lived responses
Section titled “Long-lived responses”server.write_timeout defaults to 0s, and this is the reason. A deadline on
the whole response is a deadline on the whole stream, and a stream that is meant
to stay open for minutes would be cut off mid-sequence. If you set the key for
other routes, remember that it applies to these too.
Every Write flushes, so a value reaches the client when you send it rather
than when a buffer fills. What can still hold it is something between you and the
client: a proxy that buffers, or a compressing layer that has not been told to
flush.
Not the same as progressive HTML
Section titled “Not the same as progressive HTML”An HTML page that streams — the shell first, then each region as its data
settles — is a different mechanism. That one is decided by the templates you
composed rather than by a call you made, and it is covered in
Progressive rendering. pw.WriteStream is for
responses whose content is a sequence, and it never renders a template.
What the OpenAPI document knows
Section titled “What the OpenAPI document knows”pw.WriteStream[T] call sites feed the generated document like any other typed
response. The operation is described as a streaming surface with T as the
event schema, across every media type the negotiation can select, so a client
generator has something to work from. See
API Documentation.
