5 min left
1011 words
5 minutes

Context and Cancellation in Go: A Practical Guide

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 article and the pipeline article: once goroutines compose, cancellation becomes part of the design, not a cleanup detail.

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()

2. context.WithCancel() — Manual Control#

When you need to say “stop now”:

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always call this
// pass ctx to goroutines
// call cancel() when you're done

The cancel function is idempotent. Call it once, twice, a hundred times—same result. This is important for defer patterns.

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()
}

The ctx.Done() channel closes when cancellation happens. Checking it in a select gives your goroutine a clean exit path.

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!
// add timeout
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)
}

Notice how we derive from r.Context() instead of context.Background(). This way, if the client disconnects, our timeout context inherits that 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)

Goroutines and Context#

When you spawn a goroutine, pass it the context:

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
func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]Result, len(urls))
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
}

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

Common Mistakes#

1. Storing Context in Structs#

type Service struct {
ctx context.Context // DON'T
}

Context is for function parameters, not struct fields. It makes the lifetime unclear.

2. Ignoring ctx.Err()#

select {
case <-ctx.Done():
return nil // what happened?
}

Better:

case <-ctx.Done():
return fmt.Errorf("fetch interrupted: %w", ctx.Err())

3. Not Checking Cancellation in Tight Loops#

for i := 0; i < 1e9; i++ {
// heavy computation
// never checks ctx.Done()
}

Check periodically:

for i := 0; i < 1e9; i++ {
if i % 1000 == 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
// heavy computation
}

4. Creating Background Contexts Deep in the Stack#

func helper() {
ctx := context.Background() // wrong
db.QueryContext(ctx, ...)
}

Always accept context from the caller:

func helper(ctx context.Context) {
db.QueryContext(ctx, ...)
}

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")
}

Rules for values:

  • Use for request-scoped data (trace IDs, auth tokens), not application data
  • Use typed keys to avoid collisions: type contextKey string
  • Don’t use as a replacement for function parameters
  • Document what keys your function expects

Graceful Shutdown#

The ultimate cancellation pattern: shutting down a server cleanly.

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)
defer cancel()
srv.Shutdown(shutdownCtx)
}()
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}

signal.NotifyContext creates a context that cancels on Ctrl+C. We use that to trigger a graceful shutdown with its own timeout.

Summary#

  1. Always derive from the caller’s context — never create Background() deep in the stack
  2. Pass context as the first parameter — make it obvious and consistent
  3. Check ctx.Done() in loops and selects — give your goroutines an exit
  4. Call cancel() in defer — clean up resources, prevent leaks
  5. Use WithTimeout for external calls — protect against slow dependencies
  6. Use values sparingly — they’re convenient but opaque

Context is about scope: every request gets a boundary, every goroutine lives within it, and when the boundary closes, everything inside knows to stop.

That’s how you build systems that don’t leak: memory, connections, or intentions.

Context and Cancellation in Go: A Practical Guide
https://corentings.dev/blog/go-context-cancellation/
Author
Corentin Giaufer Saubert
Published at
2026-06-17
License
CC BY-NC-SA 4.0
Share this post