8 min left
1523 words
8 minutes

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:

  1. Cancellation signals: tell goroutines to stop
  2. Deadlines: automatic cancellation after a timeout
  3. 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 early

The 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:

Terminal window
go get golang.org/x/sync/errgroup

errgroup: 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.WithContext so we can stop siblings on error
  • Pass ctx to fetch() 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 what g.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:


If you want to experiment with the code examples, you can find them on my GitHub repository.

Context and Cancellation in Go: Stopping Work That Shouldn't Have Started
https://corentings.dev/blog/go-context-cancellation/
Author
Corentin Giaufer Saubert
Published at
2026-07-04
License
CC BY-NC-SA 4.0
Share this post