skip to content

Flushing and Chunked Responses

A handler writing progressively has to Flush, or bytes sit in a buffer and the client sees nothing until you return; with no Content-Length the response goes out chunked. Event streams start here.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

Why does a net/http handler that writes a log line every second deliver nothing to the client until it returns?

level: juniorimportance: must knowfreq 42%

answer

  1. nothing is wrong with the writes themselves
  2. the bytes are still on the server
  3. returning is what finally sends them
  4. there is a way to send them early

basics

~20 s

Go's HTTP server buffers the response body, so small writes sit in memory until the handler returns. Flush after each line - with http.NewResponseController(w).Flush() or an http.Flusher type assertion - to push the bytes out.

solid answer

~50 s

Writing to an `http.ResponseWriter` does not write to the socket. The server keeps a buffer of a few kilobytes; if the handler finishes with everything still in it, the server sends the headers plus the whole body in one go. A handler that produces a line a second and never flushes therefore looks completely silent, and only at the end does the whole log dump appear at once. To stream you must ask for a push after each unit you write: `http.NewResponseController(w).Flush()`, which returns an error, or the older `if f, ok := w.(http.Flusher); ok { f.Flush() }`. The first Flush also commits the status line and header block, so anything you want in the headers must be set before it. To confirm from the terminal, use `curl -N`, which turns off curl's own output buffering.

code

go · 6 lines
go
func tail(w http.ResponseWriter, r *http.Request, lines <-chan string) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	for line := range lines {
		fmt.Fprintln(w, line) // buffered by the server, not sent
	}
}

go deeper

for a junior

Be ready to say that an http.ResponseWriter is buffered and that you must flush to stream. Know both spellings: http.NewResponseController(w).Flush() and a type assertion to http.Flusher.

for a middle

Explain the mechanics: why the server buffers at all, what the first flush commits, and why headers set after it are ignored. Expect to be asked what the buffering buys in the normal, non-streaming case.

for a senior

Show judgment about flush granularity - per line for a live tail, per batch for a bulk export - and how you prove streaming works end to end with curl -N rather than trusting a buffering client.

for a principal

Frame flushing as a contract your streaming endpoints publish: what unit is guaranteed to arrive promptly, what latency that implies, and what a consumer may assume. Endpoints that stream by accident break when someone tunes the write path.

## What actually happens to a Write An `http.ResponseWriter` is not a socket. Go's HTTP server wraps the connection in a buffered writer, and `w.Write` (or `fmt.Fprintln(w, ...)`) copies your bytes into that buffer. Nothing reaches the client until one of three things happens: 1. the buffer fills up and spills to the connection, 2. the handler returns and the server finalises the response, or 3. the handler explicitly flushes. For a live log-tail endpoint that emits one short line per second, none of the first two happen for minutes. The support engineer watching the tail sees an open connection and an empty terminal; when the process finally ends, the entire backlog arrives in one burst. The writes were never lost - they were held. ## Why the server buffers at all Buffering buys the server two things. It lets it sniff a `Content-Type` from the first bytes with `http.DetectContentType` when the handler did not set one, and it lets it compute a `Content-Length` for short responses: if the handler returns with everything still buffered, the server knows exactly how long the body is and frames the response with a length instead of chunking it. That is the right default for the overwhelmingly common case - a handler that renders a page or a JSON document and returns. Streaming is the exception, and the exception has to say so. ## Saying so The modern spelling is the response controller: ```go rc := http.NewResponseController(w) fmt.Fprintln(w, line) if err := rc.Flush(); err != nil { // this writer cannot stream; stop or fall back } ``` `Flush` on the controller returns an `error`, so a writer that cannot stream tells you (`http.ErrNotSupported`) instead of silently doing nothing. The older spelling type-asserts the interface: ```go if f, ok := w.(http.Flusher); ok { f.Flush() } ``` `http.Flusher` is a one-method interface, `Flush()`, with no return value. The assertion form is still valid and still everywhere in existing code, but it has a sharp edge: if the assertion fails you usually just skip the flush, and the failure is invisible - the handler keeps working, it merely stops streaming. ## What Flush does beyond pushing bytes The first flush commits the response. The status line goes out (200 if you never called `WriteHeader`), the header map is serialised, and from then on changes to `w.Header()` have no effect and a second `WriteHeader` call is ignored with a log line about a superfluous call. So set your `Content-Type` and anything else *before* the first write or flush. Flushing with nothing written yet is legal and useful: it sends the header block immediately, which is how a client learns the request was accepted before any data exists. One consequence people trip over: because the length is unknown at the moment the headers go out, an HTTP/1.1 response that has been flushed cannot carry `Content-Length`. The server frames it with chunked transfer encoding instead, and it does that for you - you never write chunk sizes or set `Transfer-Encoding` yourself. ## Proving it from the terminal When a tail looks stuck, the first question is whether the server is holding the bytes or the client is. `curl -N` disables curl's own output buffering; run the endpoint under it and watch. Lines appearing one per second means the handler flushes; a long silence followed by everything at once means it does not. Testing with a browser or a language client that buffers by default will hide the difference and send you looking for the bug in the wrong place. ## Cost Flushing is not free: each flush forces a write syscall and, over HTTP/1.1, a chunk header and trailer around your bytes. Flushing per line is right for a log tail where latency is the whole point; for a bulk export it is better to flush every few kilobytes or every N records, so you stream without paying a syscall per row.

  • What does http.NewResponseController(w).Flush() give you that a type assertion to http.Flusher does not?
    An error. The controller's `Flush` returns `error`, so a writer that cannot stream reports `http.ErrNotSupported` and you can log it, fail the request, or fall back deliberately. The type-assertion form gives you a boolean you usually ignore, so an unstreamable writer degrades silently into a handler that buffers everything and looks fine in tests.
  • Does flushing before writing any body bytes do anything?
    Yes. It commits the response: the status line - 200 if you never called `WriteHeader` - and the whole header block go out to the client immediately. That is how a client learns the request was accepted before any data exists. After it, changes to `w.Header()` have no effect and a later `WriteHeader` call is ignored.
  • How do you check from a terminal whether the server is streaming or your client is buffering?
    Hit the endpoint with `curl -N`, which turns off curl's own output buffering. If lines appear as they are produced, the handler is flushing and any stall is on the client side. If you get silence and then the whole body at once, the handler is not flushing. A browser or a buffering HTTP client will hide the difference entirely.
  • Is it wasteful to flush after every single line?
    It can be. Each flush costs a write syscall and, over HTTP/1.1, a chunk framing around whatever you wrote. For a log tail, where per-line latency is the whole product, that is the right trade. For a bulk export of millions of rows, flush every few kilobytes or every N records instead - you still stream, but you stop paying a syscall per row.

saying these in an interview costs you the question

  • Thinks every w.Write goes straight to the socket
  • Believes unflushed writes are lost rather than buffered
  • Sets Transfer-Encoding: chunked by hand to force streaming
  • Adds sleeps or goroutines instead of flushing
  • Assumes flushing only matters on HTTP/2
open as a page

When does Go's net/http server frame a response as chunked instead of sending a Content-Length header?

level: middleimportance: should knowfreq 38%

basics

~20 s

Whenever the body length is still unknown when the headers go out. A handler that returns with a short body buffered gets Content-Length; one that flushes first, or overruns the buffer, gets chunked framing on HTTP/1.1.

open as a page

How should a long-running net/http streaming handler notice the client disconnected and stop?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Select on r.Context().Done() inside the write loop and return when it fires - the server cancels the request context when the client goes away. Treat an error from Write or Flush as a second, later signal.

open as a page

A Go handler flushes 200 OK, then its data source fails mid-stream - how do you report the failure?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Not with a status code - the 200 is already on the wire, so http.Error would only append text to the body. Report it in the stream's own format, in an HTTP trailer, or abort with panic(http.ErrAbortHandler) so the client sees a broken response.

open as a page