Context Cancellation Done Right in Go Web Services
Use context cancellation properly in a Go web service: pass r.Context down, set deadlines in middleware, stop streaming loops, detach background work.

A web service that ignores context cancellation looks fine in testing and bleeds in production. Clients time out and retry, load balancers drop connections, users close tabs. Each time, a handler somewhere keeps running a database query, calling three other services and building a response that nobody will read. Under load this turns into a feedback loop: slow responses cause client timeouts, the abandoned work keeps the server slow, and more clients time out.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The cure is mostly discipline, applied in four places: how the context enters the handler, how it flows down, how long loops watch it, and how background work escapes it. This article covers them with code that runs. It builds on context explained and the context canceled error. Everything ran on Go 1.25.6.
Where the request context comes from
Every *http.Request has a context, r.Context(). The server cancels it in three situations: the client's connection closes, the HTTP/2 stream is reset, and the handler's ServeHTTP returns. For a graceful shutdown with Server.Shutdown, in-flight requests are allowed to finish, and BaseContext can supply a parent that you cancel to force them (graceful shutdown). So the first rule is: derive from r.Context() and not from context.Background().
Pass it down, every time
The context is only useful if each layer passes it on. A handler that receives it and calls a service with no context has cut the chain:
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
// backend stands in for a database or a downstream service.
func backend(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func service(ctx context.Context) error {
if err := backend(ctx, 30*time.Millisecond); err != nil {
return fmt.Errorf("load user: %w", err)
}
return backend(ctx, 30*time.Millisecond)
}
func handler(w http.ResponseWriter, r *http.Request) {
if err := service(r.Context()); err != nil {
if errors.Is(err, context.Canceled) {
return // the client left; nobody to answer
}
http.Error(w, "error", http.StatusInternalServerError)
return
}
fmt.Fprint(w, "ok")
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest("GET", "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
go func() {
time.Sleep(20 * time.Millisecond)
cancel() // the client disconnects mid-request
}()
start := time.Now()
handler(rec, req)
fmt.Println("handler returned after about", time.Since(start).Round(10*time.Millisecond), "not 60ms")
fmt.Println("status written:", rec.Code, "(200 is the recorder default; nothing was written)")
}handler returned after about 20ms not 60ms
status written: 200 (200 is the recorder default; nothing was written)The handler passes r.Context() to service, which passes it to backend, and the cancellation at 20 ms stops the work at the first backend call. If any layer had used its own context.Background(), the full 60 ms of work would have run. Notice also what the handler does with the error: it recognises cancellation with errors.Is and returns without writing a response, because the client is gone. Writing a 500 for a cancellation would inflate error counts for events that are not failures.
One budget: a deadline in middleware
A request should have a time budget, set once, at the edge, so every downstream call inherits it. Middleware does that:
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func withDeadline(d time.Duration, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func slow(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(300 * time.Millisecond):
fmt.Fprint(w, "finished")
case <-r.Context().Done():
http.Error(w, "request budget exceeded", http.StatusGatewayTimeout)
}
}
func main() {
h := withDeadline(50*time.Millisecond, http.HandlerFunc(slow))
rec := httptest.NewRecorder()
start := time.Now()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
fmt.Println(rec.Code, "after about", time.Since(start).Round(50*time.Millisecond))
}504 after about 50msThe middleware wraps the request context with a 50 ms timeout and gives the handler a request carrying it. The handler selects on r.Context().Done() and answers 504 when the budget runs out. A real service sets the number from a service-level objective (for example, a little under the load balancer's timeout, so the server answers before the proxy gives up on its behalf), and calls to other services get a share of the remaining time (timeouts and deadlines). The standard library has http.TimeoutHandler, which does a similar job by running the handler in a goroutine and writing a 503; the context-based version is usually better because the handler itself learns it should stop, instead of being abandoned while it continues.
Streaming and long-running handlers
Handlers that stream, server-sent events, long polling, large exports, run for a long time, and cancellation is the only way they learn the client left. They must check the context in their loop:
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func events(w http.ResponseWriter, r *http.Request) {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
n := 0
for {
select {
case <-r.Context().Done():
fmt.Println("stream ended after", n, "events:", r.Context().Err())
return
case <-ticker.C:
n++
fmt.Fprintf(w, "data: %d\n\n", n)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Millisecond)
defer cancel()
req := httptest.NewRequest("GET", "/events", nil).WithContext(ctx)
events(httptest.NewRecorder(), req)
}stream ended after 5 events: context deadline exceededThe stream sends an event every 10 ms until the context ends, about five events, and then returns. Without the case <-r.Context().Done(), the loop would run until the process died, writing into a connection that no longer exists. Every unbounded loop in a handler needs this exit, and a ticker needs its Stop (tickers).
Cancellation is not an error
Three habits keep cancellations from polluting your metrics and logs. First, classify them: errors.Is(err, context.Canceled) means the caller left, and context.DeadlineExceeded means a budget ran out, either yours (a dependency was too slow) or the client's. Second, give them their own treatment: a cancelled request writes nothing, logs at debug level and increments a separate counter, perhaps labelled with the route; an exceeded deadline is a 504 or 503 and counts as a service failure. Third, make sure database drivers and clients return the context's error unwrapped or wrapped with %w, so the classification works at the top (errors and cancellation).
Work that must outlive the request
Some work should finish even if the client disconnects: sending the confirmation email after payment, writing an audit record, enqueueing a job. Giving that work the request context cancels it when the handler returns, which is almost immediately. Detach it, keeping the values (trace and request IDs) but dropping the cancellation, and give it its own timeout:
package main
import (
"context"
"fmt"
"time"
)
type key struct{}
func sendReceipt(ctx context.Context, done chan<- string) {
select {
case <-time.After(20 * time.Millisecond):
done <- fmt.Sprint("receipt sent for ", ctx.Value(key{}), ", ctx error: ", ctx.Err())
case <-ctx.Done():
done <- "receipt aborted: " + ctx.Err().Error()
}
}
func main() {
reqCtx, cancel := context.WithCancel(context.WithValue(context.Background(), key{}, "req-42"))
bg, bgCancel := context.WithTimeout(context.WithoutCancel(reqCtx), 5*time.Second)
defer bgCancel()
done := make(chan string, 1)
go sendReceipt(bg, done)
cancel() // the handler returned and the request context ended
fmt.Println(<-done)
}receipt sent for req-42, ctx error: <nil>The receipt goroutine keeps running after the request context is cancelled, because it holds a derived context that no longer inherits cancellation, and it still has the request ID for its logs. Its own five-second timeout bounds how long it can run. For anything that must survive a process restart, a queue is the better home than a goroutine; a detached context protects against a client disconnect, not against a deploy (AfterFunc and WithoutCancel).
Test it
Cancellation behavior is easy to test and rarely tested. Build the request with an already-cancelled context and assert that the handler returns quickly and does not call the backend:
package main
import (
"context"
"net/http/httptest"
"testing"
"time"
)
func TestHandlerStopsWhenClientIsGone(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // the client is already gone
req := httptest.NewRequest("GET", "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
start := time.Now()
handler(rec, req)
if elapsed := time.Since(start); elapsed > 20*time.Millisecond {
t.Errorf("handler took %v with a cancelled context; it should return at once", elapsed)
}
if rec.Body.Len() != 0 {
t.Errorf("handler wrote %q to a client that left", rec.Body.String())
}
}=== RUN TestHandlerStopsWhenClientIsGone
--- PASS: TestHandlerStopsWhenClientIsGone (0.00s)
PASS
ok example.test/lab 1.277sThe test catches the two usual defects: a layer that ignores the context (the elapsed time is long) and a handler that writes to a client that is gone. Add a second test with a short deadline to check the 504 path. For deterministic timing, testing/synctest removes the sleeps (testing with synctest).
Propagating the deadline to other services
A request deadline set at the edge should reach the services you call, or the budget stops at your process boundary. With net/http clients, passing the request's context to http.NewRequestWithContext enforces the deadline locally: the client call returns when the budget is spent. To tell the other service, send the remaining time in a header and let its middleware apply it. gRPC does this natively, putting the deadline in the call metadata, and for HTTP services a convention such as a X-Request-Timeout header, read in the same middleware shown above, achieves the same. The reasoning is that downstream work for a request that has already timed out upstream is wasted, and when every hop honors the budget the waste is cut off at the first hop that notices. Subtract a margin at each hop so a downstream call fails before the upstream one does, giving the upstream service time to answer something sensible, like a 504 with a message, instead of its own timeout.
What http.Server timeouts do and do not cover
The server's ReadTimeout, WriteTimeout and IdleTimeout bound the connection, and they are not a substitute for request contexts. WriteTimeout covers the time to write the response, starting when the header was read, so a handler that takes longer than that fails to write. It does not cancel the handler. The context is what tells handler code to stop, so the two work together: connection-level timeouts protect the server from slow or malicious clients, and context deadlines protect the work from slow dependencies. Set ReadHeaderTimeout at the very least; a server without one can be held open indefinitely by a client that sends a header byte by byte.
Checklist
- Derive every context from
r.Context(). - Set one deadline at the edge and let it flow.
- Pass
ctxto every call that accepts it: queries, HTTP clients, RPCs. - Select on
ctx.Done()in every loop and stream. - Recognise cancellation, and respond quietly.
- Detach deliberately for background work, with its own timeout.
- Test with a cancelled context.
The net/http documentation on Request.Context lists exactly when the server cancels it, and the Go blog's Context article motivates the design.


