Module 6 of 6
Module 6: Context — Propagation & Cancellation
Master context.Context for cancellation, deadlines, and request-scoped values — the standard Go pattern for goroutine lifecycle management.
Module 6: Context — Propagation & Cancellation
The mental model shift
Remember the done channel pattern from Module 2? The one where closing a channel signals all listening goroutines to stop? That pattern was so important, so universal, that the Go team standardized it as context.Context. And then they added two more superpowers.
context.Context is three things rolled into one elegant package:
- Cancellation —
ctx.Done()returns a channel that closes when the context is cancelled. This is exactly thedonechannel pattern, blessed by the standard library. - Deadlines —
context.WithTimeoutandcontext.WithDeadlineattach an expiry. When time runs out,ctx.Done()closes automatically. No manual timer management. - Request-scoped values —
context.WithValueattaches key-value pairs that flow through the call tree. Request IDs, auth tokens, trace spans — data that crosses API boundaries but shouldn't pollute function signatures.
The discipline that makes context work: context flows down the call tree, never up. A parent can cancel a child. A child can never cancel its parent. This is enforced by the API — WithCancel, WithTimeout, WithDeadline, and WithValue all return a derived context. You can't modify the parent. You can only extend it downward.
And here's the one rule you cannot break: call the cancel function. Every WithCancel, WithTimeout, and WithDeadline returns a cancel function. If you don't call it (usually via defer), the context and all its resources will leak. The linter won't catch this. The race detector won't catch this. Only code review and vigilance will.
Key concepts
context.Background()— the root of all context trees. Never cancelled, never times out. Use atmain()or test entry points.context.TODO()— a placeholder. It means "I know context should flow through here, but I haven't wired it up yet." Never ship code with TODO contexts.ctx.Done()— returns a channel. It closes when the context is cancelled or the deadline passes. All goroutines watching this channel get the signal simultaneously.ctx.Err()— returnsnilwhile the context is alive,context.Canceledif explicitly cancelled, orcontext.DeadlineExceededif a timeout fired.ctx.Value(key)— retrieves a value from the context chain. Use unexported key types to prevent collisions between packages.defer cancel()immediately afterWithCancel/WithTimeout/WithDeadline. Every time. No exceptions.
Drill 1 — Timeout propagation
package main
import (
"context"
"fmt"
"time"
)
func slowOperation(ctx context.Context, name string) error {
select {
case <-time.After(500 * time.Millisecond): // simulates slow work
fmt.Printf("%s: completed\n", name)
return nil
case <-ctx.Done():
fmt.Printf("%s: cancelled (%v)\n", name, ctx.Err())
return ctx.Err()
}
}
func main() {
// Case 1: timeout fires before work completes
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
err := slowOperation(ctx, "op1")
fmt.Println("err:", err) // context deadline exceeded
// Case 2: work completes before timeout
ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel2()
err = slowOperation(ctx2, "op2")
fmt.Println("err:", err) // nil
// Case 3: manual cancellation from another goroutine
ctx3, cancel3 := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel3() // fire! — ctx3.Done() closes
}()
err = slowOperation(ctx3, "op3")
fmt.Println("err:", err) // context canceled
}Run this and watch the timing. Case 1 returns context deadline exceeded after 200ms. Case 2 completes normally. Case 3 is cancelled by another goroutine. Three different triggers, one unified pattern: check ctx.Done().
Drill 2 — Context values (and when NOT to use them)
package main
import (
"context"
"fmt"
)
// Unexported key type — prevents collisions with other packages
type contextKey string
const (
requestIDKey contextKey = "request_id"
userIDKey contextKey = "user_id"
)
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func requestIDFrom(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}
func handler(ctx context.Context) {
reqID, ok := requestIDFrom(ctx)
if !ok {
fmt.Println("no request ID in context")
return
}
fmt.Printf("handling request %s\n", reqID)
// ctx flows down — request ID comes along for free
dbQuery(ctx)
}
func dbQuery(ctx context.Context) {
reqID, _ := requestIDFrom(ctx)
fmt.Printf(" db query for request %s\n", reqID)
}
func main() {
ctx := context.Background()
ctx = withRequestID(ctx, "req-abc-123")
handler(ctx)
}My rule for context values: use them ONLY for request-scoped metadata that crosses API boundaries — request IDs, auth tokens, trace spans, user identity. Never use them for optional function parameters or data that affects business logic. If a value changes how your code behaves, pass it explicitly. If a value only needs to be observed (logged, traced, metered), context is appropriate.
The unexported key type is not optional — it prevents two packages from using the same string key and silently overwriting each other's values. I've debugged this. It's not fun.
Drill 3 — Cancellation broadcasts to all goroutines
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d: stopping (%v)\n", id, ctx.Err())
return
default:
fmt.Printf("worker %d: working\n", id)
time.Sleep(100 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
defer cancel()
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(ctx, i, &wg)
}
wg.Wait()
fmt.Println("all workers stopped")
}Watch what happens: all three workers receive the cancellation signal from a single context. One timeout. Three goroutines stopped. This is the broadcast pattern from Module 2's done channel, now with deadline support and a standard API.
Capstone — Graceful shutdown server
This is the capstone that ties together goroutines (Module 1), channels (Module 2), error handling (Module 4), and context (this module). Build an HTTP server that shuts down gracefully:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/slow", slowHandler)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Start server in goroutine
go func() {
log.Println("listening on :8080")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// Wait for signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
// TODO: use context.WithTimeout for graceful shutdown window
// TODO: srv.Shutdown(ctx)
}
func slowHandler(w http.ResponseWriter, r *http.Request) {
// TODO: respect r.Context() cancellation
// TODO: log request ID from context
time.Sleep(2 * time.Second)
w.Write([]byte("done\n"))
}Your job: fill in the TODOs. Use context.WithTimeout for a 5-second shutdown window. Use srv.Shutdown(ctx). Use r.Context() in the handler so slow requests respect cancellation. Add request IDs via context values.
How to test: Start the server, hit /slow in one terminal, and immediately Ctrl-C the server. The in-flight request should complete before the server exits. This is the difference between a service that loses data and one that doesn't. When it works, you've mastered all six modules.