Context and Cancellation in Go: Stopping Work That Shouldn't Have Started
Every goroutine you spawn is a promise that something will eventually stop. If you don’t control when that happens, you leak intention: your program keeps doing work nobody asked for, long after the caller moved on.
Go’s context package is the standard answer. But like most standard answers, it’s easy to use poorly and hard to use well. This builds on the channel patterns from the Worker Pool pattern and the Pipeline pattern: once goroutines compose, cancellation becomes a design constraint you wire in from the start, not a cleanup detail you bolt on after.
The Problem: Orphaned Work
Imagine a handler that queries three databases in parallel:
func handleRequest(w http.ResponseWriter, r *http.Request) { var wg sync.WaitGroup wg.Add(3)
go func() { defer wg.Done(); queryDB1() }() go func() { defer wg.Done(); queryDB2() }() go func() { defer wg.Done(); queryDB3() }()
wg.Wait() // ... respond}What happens when the client disconnects after 50ms? The goroutines keep running. The databases keep working. Resources burn for a request that will never be answered.
This is the orphaned work problem. And it’s everywhere in concurrent code.
What Context Gives You
context.Context is a request-scoped value that carries:
- Cancellation signals: tell goroutines to stop
- Deadlines: automatic cancellation after a timeout
- Key-value pairs: request-scoped metadata (trace IDs, user info)
The key insight: context flows down. You create it at the top of a request, pass it to every function and goroutine, and everything respects the same lifecycle.
The Three Flavors
1. context.Background() — The Root
Use this at the top level: main, test setup, initialization. It never cancels. Everything else derives from it.
ctx := context.Background()A sibling: context.TODO(), a temporary placeholder for when you’re refactoring toward context-aware code but the caller isn’t wired up yet. It should disappear in the next commit.
The most common mistake: someone reaches for context.Background() three layers deep because it’s easier than threading a parameter through:
func helper() { ctx := context.Background() // wrong db.QueryContext(ctx, ...)}That breaks the chain. Every hop back up to find the parent makes cancellation meaningless. Always accept the context from your caller.
2. context.WithCancel() — Manual Control
When you need to say “stop now”:
ctx, cancel := context.WithCancel(context.Background())defer cancel() // releases the timer earlyThe cancel function is idempotent. Call it once, twice, a hundred times; same result. This matters because the typical pattern is to defer cancel() immediately after creating the context — even if another code path has already cancelled, calling it again is a no-op, not an error.
3. context.WithTimeout() / WithDeadline() — Time-Bound
When you need to say “stop after 2 seconds”:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()The context automatically cancels when the deadline hits. You still call cancel() in defer to release resources early if you finish sooner.
Listening for Cancellation
A context doesn’t stop goroutines by force. It asks politely. You have to listen:
func queryWithContext(ctx context.Context, db *sql.DB) error { rows, err := db.QueryContext(ctx, "SELECT ...") if err != nil { return err } defer rows.Close()
for rows.Next() { select { case <-ctx.Done(): return ctx.Err() // context cancelled default: // process row } } return rows.Err()}db.QueryContext and rows.Next() already watch the context for you: they return context.DeadlineExceeded or context.Canceled as soon as the context is done. The explicit select here is belt-and-suspenders for the body of the row loop, where the driver has handed control back to you.
A common mistake: a goroutine does <-ctx.Done() and silently returns nil. The caller has no idea anything went wrong. Retries pile up, and production logs a vague “intermittent failure.” Wrap the cause with ctx.Err() so the error chain carries the cause back to the source.
The Pattern: Per-Request Context
In HTTP handlers, the request already carries a context:
func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // use this! ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel()
result, err := service.Process(ctx, r.Body) if err != nil { if errors.Is(err, context.DeadlineExceeded) { http.Error(w, "timeout", http.StatusGatewayTimeout) return } http.Error(w, err.Error(), http.StatusInternalServerError) return }
json.NewEncoder(w).Encode(result)}We derive from r.Context() so the timeout context inherits client-disconnect cancellation.
Propagation: The Golden Rule
Every function that does I/O or spawns goroutines should take context.Context as its first parameter.
This is the convention. Follow it even when you don’t think you need it. The caller knows their constraints better than you do.
Bad:
func FetchUser(userID string) (*User, error)Good:
func FetchUser(ctx context.Context, userID string) (*User, error)Here’s the trap: someone stuffs ctx into a struct field because “this service needs context everywhere”:
type Service struct { ctx context.Context // DON'T}A Service with a ctx field has an ambiguous lifetime: is the request still alive, or are we holding onto a cancelled context the next call will inherit? Context is a parameter. Keep it out of structs.
Goroutines and Context
Pass context to every goroutine you spawn. For fan-out work, golang.org/x/sync/errgroup gives you cancellation and error propagation without hand-rolling channel bookkeeping:
go get golang.org/x/sync/errgrouperrgroup: fan-out with cancellation
func fetchAll(ctx context.Context, urls []string) ([]Result, error) { g, ctx := errgroup.WithContext(ctx)
results := make([]Result, len(urls))
g.SetLimit(8) // cap concurrency so we don't hammer the database
for i, url := range urls { idx, u := i, url g.Go(func() error { res, err := fetch(ctx, u) if err != nil { return err } results[idx] = res return nil }) }
if err := g.Wait(); err != nil { return nil, err }
return results, nil}SetLimit(N) caps how many of those goroutines run at the same time, useful when urls is ten thousand and you don’t want to DDoS your own database. Excess goroutines queue inside the errgroup and start as in-flight ones finish.
Key points:
- Derive the child context with
errgroup.WithContextso we can stop siblings on error - Pass
ctxtofetch()so it can respect cancellation g.Wait()waits for every goroutine and returns the first error- Each goroutine writes to a distinct slot of
results(results[idx]), so the writes don’t share memory and need no synchronization beyond whatg.Wait()already gives you
In tight loops I watch for: a for chewing through a million items without ever checking ctx.Done(). The cancellation is wired up in spirit but useless in practice. Your goroutine only finds out it was supposed to stop when the result is no longer wanted. Drop a cheap check inside the hot path:
for i := 0; i < 1e9; i++ { if i % 1000 == 0 { select { case <-ctx.Done(): return ctx.Err() default: } } // heavy computation}Once every thousand iterations is cheap enough not to matter, and it gives cancellation a real chance to propagate.
Values: Use Sparingly
Context values carry request-scoped metadata:
type contextKey string
const traceIDKey contextKey = "traceID"
ctx = context.WithValue(ctx, traceIDKey, traceID)Access them with a type check:
traceID, ok := ctx.Value(traceIDKey).(string)if !ok { return errors.New("missing trace ID")}Reach for a context value only when the data is genuinely request-scoped: trace IDs, auth principals, locale, the kind of thing that would otherwise have to be threaded through every function on the call path. Treat the context package as a request-scoped bag. Typed keys prevent collisions, and context should never be the only place something lives.
If a function needs data to do its job, that data is an argument; if it’s just convenient, the context can carry it. Document which keys your code reads. The compiler won’t remind you.
Graceful Shutdown
A long-running server with thousands of requests and hundreds of goroutines per second needs a way to say “we’re done” and have it propagate through every layer. That’s graceful shutdown: take a SIGTERM, cancel the root context, watch every active request wind down, exit cleanly. Without it, Kubernetes kicks the pod, mid-flight queries get truncated, and the next deploy starts with a cascade of panics from leaked goroutines.
func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop()
srv := &http.Server{ Addr: ":8080", Handler: http.HandlerFunc(handler), }
go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // fresh root: signal ctx is already cancelled defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Printf("graceful shutdown failed: %v", err) } }()
if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatal(err) }}Read this from the bottom up. ListenAndServe blocks until either the server fails or Shutdown is called. The goroutine waits for ctx.Done() (the signal), opens a fresh 30-second timeout context, and walks connections to a clean goodbye via srv.Shutdown. http.ErrServerClosed is the success signal from ListenAndServe; anything else is a real error worth log.Fatal-ing. signal.NotifyContext ties it all to a single cancellation source: the same context flows through handlers, workers, and DB calls.
Testing Cancellation
The real test for a context-aware function is whether it stops when asked. Returning the right value is table stakes; cancellation is the contract. A 5-line test using context.WithTimeout(ctx, 1*time.Millisecond) and a slow stub is usually enough to prove it. I wrote about testing the boundary in TDD Isn’t About Bugs — It’s Your Permission to Refactor.
Summary
Context is about scope: every request gets a boundary, every goroutine lives within it, and when the boundary closes, everything inside knows to stop. Pass it from the top down, always derive, always call cancel in a defer. That’s how you build systems that don’t leak memory, connections, or intentions.
Series Navigation
This article is part of the Go Patterns series:
- Previous: Go Pipeline Pattern: Turning Streams into Useful Data
- Next: Go Goroutines vs C# async/await: Who Carries the Cognitive Load?
- Series: Go Patterns
If you want to experiment with the code examples, you can find them on my GitHub repository.
Related Posts
Go Goroutines vs C# async/await: Who Carries the Cognitive Load?
Goroutines and async/await solve the same problem from opposite assumptions. A polyglot's take on primitives vs compiler-managed patterns.
Go Pipeline Pattern: Turning Streams into Useful Data
Learn the Pipeline Pattern in Go using goroutines and channels. Build composable stages for parsing, filtering, enriching, and processing log streams.
Mastering the Worker Pool Pattern in Go
Master the Worker Pool Pattern in Go to manage concurrent tasks efficiently. Control resource usage, improve throughput, and scale your applications.