skip to content

How does a Go HTTP handler notice that the client hung up mid-request?

level: middleimportance: should knowfreq 55%

answer

  1. the server detects it, not you
  2. it arrives as a cancellation
  3. select on Done, or a failed downstream call
  4. writing to the response proves nothing
  5. do not count it as a 5xx

basics

~20 s

Through the request context. Go's server watches the connection while the handler runs and cancels r.Context() when the peer goes away, so a select on ctx.Done(), or a downstream call returning context.Canceled, is how the handler finds out.

solid answer

~50 s

The server does the detection for you. While your handler runs, net/http keeps a read pending on the connection; when that read reports the peer closed — or an HTTP/2 stream is reset — it cancels `r.Context()`. Inside the handler you see it two ways: a `select` on `<-ctx.Done()` fires, or a context-aware downstream call such as `QueryContext` returns an error matching `context.Canceled`. Writing to the `http.ResponseWriter` is not a reliable detector: the write can succeed into a buffer that never reaches anyone. The right reaction is to stop the expensive work and return promptly — there is nobody to send a response to — and to classify the event correctly in telemetry. A disconnect is a client-side outcome, not a 500; counting it as a server error turns every impatient caller into a false alarm on your error-rate alert.

code

go · 8 lines
go
rows, err := db.QueryContext(ctx, query, id)
if err != nil {
	if errors.Is(err, context.Canceled) && ctx.Err() != nil {
		return // caller went away: nothing to send, not a 5xx
	}
	http.Error(w, "lookup failed", http.StatusInternalServerError)
	return
}

go deeper

for a junior

Know that you do not detect a disconnect yourself: the server cancels the request context and you watch ctx.Done(). Mention passing that context into database and outbound calls so they stop too.

for a middle

Explain that net/http keeps a read on the connection while the handler runs and cancels the request context when the peer closes, and that the observation reaches you either through a select or through a context-aware call returning a cancellation error.

for a senior

Demonstrate the operational judgment: disconnects are normal traffic, so they belong in their own metric and out of the error budget, while a spike in them usually means an upstream deadline is now shorter than your latency.

for a principal

Frame it as policy: how the platform classifies abandoned requests, what the shared middleware logs and counts, and how teams stop cancellation noise from either hiding real failures or paging people at 3am.

## The mechanism Go's HTTP server does not make you poll for a dead client. For each connection it runs a reader that, while a handler is executing, is blocked reading the connection. That read exists partly to spot pipelined data and partly to notice the peer closing. When the connection dies, the server cancels the context it created for the in-flight request. Under HTTP/2 the same happens when the peer sends a stream reset. The handler observes it through the ordinary context surface: `ctx.Done()` closes and `ctx.Err()` returns `context.Canceled`. ## Two ways to observe it **Directly, with a select.** If your handler owns a long-running loop or waits on a channel, add a `case <-ctx.Done():` and bail out. ```go for _, chunk := range work { select { case <-ctx.Done(): return // caller is gone default: } process(chunk) } ``` **Indirectly, through downstream calls.** This is the common case and needs no code at all, provided you threaded the context: `(*sql.DB).QueryContext`, an outbound request built with the context, and any well-behaved library return promptly with an error that satisfies `errors.Is(err, context.Canceled)` once the context is cancelled. Not everything is cancellable — a plain file read, for instance, is not interruptible by a context — but the network- and database-facing calls that dominate a handler's latency are. ## What is not a detector Writing to the `http.ResponseWriter` is unreliable. A `Write` can land in the server's buffer or in the kernel's socket buffer and report success long after the peer has gone; conversely a failed write does not tell you *why*. Treat the context as the signal and the writer as an output. Also beware the ambiguity of a cancelled context *after* your handler is finished: because returning from `ServeHTTP` cancels the same context, a cancelled context observed in a goroutine that outlives the handler tells you nothing about the client. Inside the handler's own body, before it returns, a cancelled request context does mean the peer went away. ## What to do about it **Stop the work.** The whole point of propagating the context is that abandonment reaches the edges. Return as soon as you can; there is no response to deliver. **Do not fabricate an error response.** Writing `http.Error(w, ..., 500)` to a closed connection is harmless but pointless, and if you also log it at error level you have manufactured a fake incident. **Classify it correctly.** This is the part that separates a middle answer from a good one. Disconnects are normal: users navigate away, mobile networks drop, upstream proxies enforce their own deadlines. Log them at debug or info, count them under their own label, and keep them out of the error-rate signal that pages someone. A sudden *spike* in disconnects is worth an alert of its own — it usually means an upstream timeout is now shorter than your latency — but each individual one is not a server fault. **Beware unwinding cleanup that needs the context.** If your handler's deferred cleanup issues a database call using the request context, that call fails once the client has gone. Cleanup that must complete needs a context detached from the request, with its own short timeout. ## Distinguishing a disconnect from a deadline If middleware wrapped the request in a `context.WithTimeout`, a cancelled context inside the handler may mean either the client left or your own deadline expired. `ctx.Err()` tells them apart: `context.Canceled` versus `context.DeadlineExceeded`. Log which one it was; the two demand different fixes — one is a client-side event, the other is your budget being too tight or your dependency too slow.

  • Why is a successful w.Write not proof that the client is still there?
    Because the write only has to reach a buffer. The server's own buffering, the socket send buffer and any proxy in between can all accept bytes that never reach the peer, and the error surfaces later or not at all. The request context is the signal; the writer is not a health check.
  • If middleware wrapped the request with context.WithTimeout, how do you tell a disconnect from an expired deadline?
    Inspect `ctx.Err()`. A client that hung up gives `context.Canceled`; a deadline that elapsed gives `context.DeadlineExceeded`. Log which one occurred, because they point at different problems — an impatient or vanished caller versus a latency budget you are actually blowing.
  • Your handler defers a cleanup write that uses the request context. What goes wrong when the client disconnects?
    The cleanup call fails with a cancellation error, because the deferred code runs while the request context is already cancelled — and it would fail even on the happy path if the defer ran after ServeHTTP returned. Cleanup that must complete needs a detached context with its own short timeout.

saying these in an interview costs you the question

  • Polls the connection or writes probe bytes to test the client
  • Treats a successful Write as proof the client is connected
  • Logs every client disconnect at error level as a 500
  • Reaches for http.CloseNotifier in new code
  • Assumes the handler is killed automatically when the client leaves
  • Confuses context.Canceled with context.DeadlineExceeded in logs