skip to content

In a Go HTTP handler, what does r.Context() return and what cancels it?

level: juniorimportance: must knowfreq 72%

answer

  1. one context per request
  2. two events end it, not one
  3. the client can hang up
  4. returning from ServeHTTP also cancels
  5. so fire-and-forget work dies instantly

basics

~20 s

r.Context() returns a context.Context scoped to that one HTTP request. Go's net/http server cancels it when the client's connection goes away and, unconditionally, when your handler returns from ServeHTTP. Pass it to every downstream call.

solid answer

~40 s

Every `*http.Request` handled by Go's server carries a per-request `context.Context`, reachable with `r.Context()`. The server cancels it in two situations: the client goes away — the connection closes, or an HTTP/2 stream is reset — and, always, the moment your handler returns from `ServeHTTP`. That makes it the right context to thread into database queries, outbound calls and any goroutine whose work only matters while the request is alive: if the caller hangs up, the whole chain unwinds instead of burning CPU on a response nobody will read. It is also why fire-and-forget work must not use it — a goroutine still running after the handler returned sees an already-cancelled context. Before contexts existed you detected a disconnect with `http.CloseNotifier`; that interface is deprecated and `r.Context()` replaces it.

code

go · 10 lines
go
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	select {
	case res := <-slowLookup(ctx):
		fmt.Fprint(w, res)
	case <-ctx.Done():
		// client hung up, or the handler is unwinding
		log.Println("abandoned:", ctx.Err())
	}
}

go deeper

for a junior

Be ready to say what r.Context() is and to name both cancellation events out loud: the client going away, and your handler returning. Then show that you pass it to database and outbound calls rather than context.Background().

for a middle

Explain the mechanics: the server watches the connection in the background, cancellation closes Done() and sets Err() to context.Canceled, and the handler's return is itself a cancel. Draw the corollary about goroutines and about r.Body and the ResponseWriter becoming invalid.

for a senior

Show the operational side: abandonment propagating downstream is how a service sheds load when callers give up, and disconnect-driven context.Canceled should not be counted as a server error in your dashboards or alerting.

for a principal

Own the convention: every function that does I/O takes a ctx as its first argument, so the request's cancellation actually reaches the edges. Be able to say what your codebase does about work that must outlive the response, and why that is a deliberate exception rather than a default.

## What `r.Context()` is A `context.Context` in Go is a small immutable value carried as the first parameter through a call chain. It provides three things: a `Done()` channel that is closed when the work should stop, an `Err()` that says why, and a set of request-scoped values. Go's HTTP server creates one such context per incoming request and hangs it off the `*http.Request`. Inside a handler you get it with `r.Context()`, and for a server request it is never nil — the server always installs one. ```go func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // ctx belongs to this request and nothing else } ``` ## The two things that cancel it **1. The client goes away.** On HTTP/1.1 the server keeps a background read running on the connection while your handler executes; when that read reports the peer closed the connection, the server cancels the request context. On HTTP/2 the equivalent signal is the peer resetting the stream. Either way the cancellation shows up as `ctx.Done()` being closed and `ctx.Err()` returning `context.Canceled`. **2. Your handler returns.** This one surprises people. `ServeHTTP` returning is itself a cancellation event: the server cancels the request context as the handler unwinds, whether the request succeeded, failed, or the client is still perfectly happy. The context's lifetime is the handler's dynamic extent, not the response's journey to the browser. There is no third source. A per-request deadline only exists if something upstream — a piece of middleware, or your own code — derived a new context with a timeout from it. ## Why this design is useful Because the context is cancelled on disconnect, threading it downwards makes abandonment propagate for free. `database/sql`'s `QueryContext`, an outbound `http.Client` call built with the request's context, and any code of yours that selects on `ctx.Done()` all stop when the caller stops caring. On a service under load, that is the difference between shedding work when clients time out and grinding through queries whose results will be thrown away. ```go select { case res := <-slow(ctx): fmt.Fprint(w, res) case <-ctx.Done(): log.Println("abandoned:", ctx.Err()) } ``` ## Why it bites The second cancellation event is the one that produces bugs. Code like this looks harmless: ```go go writeAuditRecord(r.Context(), rec) // dies almost immediately ``` The handler returns, the server cancels the context, and the goroutine's first context-aware call fails with `context.Canceled`. The work silently does not happen. If work must outlive the response, it needs a context that is deliberately detached from the request's — that is what `context.WithoutCancel` is for — and a bound of its own. The same lifetime rule governs the other two things the server hands you. `r.Body` is closed by the server once `ServeHTTP` returns, and the `http.ResponseWriter` must not be used after the handler returns, because the connection may already be serving the next request. Anything a background goroutine needs must be copied out of the request while the handler is still on the stack. ## The historical alternative Before the request context existed, the way to learn that a client had hung up was the `http.CloseNotifier` interface: type-assert the `http.ResponseWriter` to it and receive from `CloseNotify()`. It is deprecated; `r.Context()` covers the same ground and composes with everything else that takes a context, so new code should never reach for it. ## What a good answer includes Name both cancellation events, not just the disconnect. Say that the context is the propagation vehicle for abandonment, and that cancelling it is a local signal — nothing is sent to the client, and no status code is written on your behalf. And state the corollary: work that must survive the response cannot ride on the request's context.

  • Does the context returned by r.Context() carry a deadline of its own?
    Not by itself. The request context is cancellable but has no deadline unless something derived one — your middleware or handler calling `context.WithTimeout`. Check with `ctx.Deadline()`, which reports `ok == false` when none is set. Server-level read and write timeouts are a separate mechanism that ends the request from the outside.
  • What happens if a goroutine writes to the http.ResponseWriter after the handler has returned?
    It is invalid. A `http.ResponseWriter` may not be used after `ServeHTTP` returns: the response is finished and the connection may already be handling the next request. At best the write is discarded, at worst it races the server's own use of that connection. Copy out what a background goroutine needs before returning.
  • Does cancelling the request context send anything to the client?
    No. Cancellation is purely a local signal to Go code holding that context. It writes no status code, closes no connection and notifies nobody. If the client is still connected and you decide to abandon work, you still have to write a response yourself before returning.

saying these in an interview costs you the question

  • Says the request context is only cancelled when the client disconnects
  • Believes it stays alive until the response reaches the browser
  • Passes context.Background() to downstream calls inside a handler
  • Thinks cancelling the context writes a status code to the client
  • Hands r.Context() to a goroutine meant to outlive the response
  • Assumes every request context arrives with a deadline already set