July 5, 2026
Context cancellation in Go: getting it right

I used to treat context.Context as a thing you pass around because the linter wants a first argument, not because it does anything. Then I spent an afternoon staring at a service that kept hitting the database for requests whose clients had already given up and closed the connection. The context was there, threaded through every function signature like it should be. Nobody was actually listening to it.
That’s the part that trips people up. Having a ctx context.Context parameter doesn’t cancel anything by itself. Cancellation only works if something downstream checks ctx.Done() or ctx.Err(), and if nothing does, the context is just a parameter you’re carrying around for decoration.
Here’s the handler that started the investigation:
func (h *Handler) GetReport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, err := h.userSvc.FetchUser(ctx, r.PathValue("id"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
report := buildReport(user)
json.NewEncoder(w).Encode(report)
}
func buildReport(user User) Report {
rows, _ := someDB.Query("SELECT * FROM events WHERE user_id = ?", user.ID)
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
rows.Scan(&e.ID, &e.Payload)
events = append(events, e)
}
return Report{User: user, Events: events}
}
ctx gets used exactly once, to fetch the user. buildReport never sees it, so the query behind it runs to completion no matter what happens to the client. If the client disconnects halfway through, or the query is slow because of a lock somewhere, that query keeps running, holding a connection from the pool, doing work nobody will read. Multiply that by a few thousand requests a minute during a slow patch and you’ve got a pool exhaustion problem that has nothing to do with your actual traffic volume.
The fix is to let the context flow all the way down and to use the context-aware variants of the calls that support them:
func (h *Handler) GetReport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, err := h.userSvc.FetchUser(ctx, r.PathValue("id"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
report, err := buildReport(ctx, user)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(report)
}
func buildReport(ctx context.Context, user User) (Report, error) {
rows, err := someDB.QueryContext(ctx, "SELECT * FROM events WHERE user_id = ?", user.ID)
if err != nil {
return Report{}, err
}
defer rows.Close()
var events []Event
for rows.Next() {
if err := ctx.Err(); err != nil {
return Report{}, err
}
var e Event
if err := rows.Scan(&e.ID, &e.Payload); err != nil {
return Report{}, err
}
events = append(events, e)
}
return Report{User: user, Events: events}, nil
}
r.Context() is already tied to the request. Go’s HTTP server cancels it the moment the client disconnects, so once QueryContext is used instead of Query, the database driver gets that cancellation and can abort the query on the connection instead of letting it run to the end. The ctx.Err() check inside the loop matters too for anything that isn’t a single blocking call, if you’re iterating and doing meaningful work per row, checking ctx.Err() periodically stops you from grinding through a large result set after the caller has already left.
The other place this bites people is picking the wrong constructor. context.WithCancel gives you a cancel function and nothing else, it’s for when you have your own stopping condition, like a worker that should exit when its parent decides it’s done. context.WithTimeout and context.WithDeadline are the same thing under the hood, a timeout is just “deadline relative to now” instead of an absolute time, and both cancel automatically. Use a timeout for anything calling out to a network dependency, a downstream HTTP call or database query should never be allowed to run indefinitely just because the caller forgot to set a bound:
func fetchPricing(ctx context.Context, sku string) (Price, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, pricingURL+sku, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Price{}, err
}
defer resp.Body.Close()
// decode resp.Body into Price
return decodePrice(resp.Body)
}
The defer cancel() isn’t optional cleanup, it’s load-bearing. Every WithCancel, WithTimeout, and WithDeadline allocates a context that has to be released, and skipping the deferred cancel leaks resources tied to the parent context’s tree until the parent itself is cancelled or the timeout fires. On a hot path called thousands of times a minute, that’s a slow accumulation of goroutines and timers that looks exactly like the kind of leak I wrote about with unbuffered channels, just with a different root cause.
The pattern I try to hold onto now: context.Background() only belongs at the very top, in main or in a background job that genuinely has no parent request. Everything else should take a context as its first argument, pass it to whatever it calls, and actually check it if the work inside is more than a single already-context-aware library call. Context cancellation isn’t a nice-to-have wired in for API symmetry, it’s how “the caller stopped caring” gets communicated down a call chain that might be five functions deep by the time it reaches the thing actually doing the work.