Context is Go’s answer to cancellation, deadlines, and request-scoped state. Used right, your services shut down cleanly, slow operations get killed, and goroutines don’t leak. Used wrong, you have phantom goroutines and zombie requests. This post is the working set.

The mental model

A context.Context is a tree:

Request comes in → ctx with timeout 30s
  ↓ derive ctx for DB call → timeout 5s
  ↓ derive ctx for cache lookup → timeout 100ms
  ↓ derive ctx for LLM call → timeout 20s

Cancel the parent → all children cancel. Set a tighter deadline on a child → the child times out first.

The basic forms

ctx, cancel := context.WithCancel(parent)
defer cancel()

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

ctx, cancel := context.WithDeadline(parent, time.Now().Add(time.Hour))
defer cancel()

ctx := context.WithValue(parent, RequestIDKey, id)

Every WithCancel / WithTimeout / WithDeadline returns a cancel func. Always defer cancel. Failing to do so leaks resources.

HTTP server: every request has a context

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()  // cancelled when client disconnects
    
    rows, err := db.QueryContext(ctx, "SELECT ... ")
    if err != nil { ... }
}

Client disconnects → ctx cancelled → DB query gets killed. No wasted compute.

errgroup for concurrent calls

import "golang.org/x/sync/errgroup"

func handler(ctx context.Context) error {
    g, ctx := errgroup.WithContext(ctx)
    var user User
    var orders []Order
    
    g.Go(func() error {
        return db.QueryRowContext(ctx, ...).Scan(&user)
    })
    g.Go(func() error {
        rs, err := db.QueryContext(ctx, ...)
        if err != nil { return err }
        for rs.Next() { ... }
        return rs.Err()
    })
    
    if err := g.Wait(); err != nil {
        return err
    }
    // both queries succeeded
    return nil
}

If one fails: ctx cancels; the other gets the cancel signal and returns. Total time = max of the two, not sum. See Go Concurrency .

Timeout per dependency

Different layers warrant different timeouts:

func handler(ctx context.Context) error {
    // DB: tight
    dbCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
    defer cancel()
    user, err := loadUser(dbCtx)
    if err != nil { return err }
    
    // LLM: looser
    llmCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
    defer cancel()
    summary, err := llm.Summarize(llmCtx, user.Bio)
    if err != nil { return err }
    
    return nil
}

Each derived context inherits the outer deadline. If the parent is 1s left, the child can’t extend it — WithTimeout only tightens.

Request-scoped values

type ctxKey int
const RequestIDKey ctxKey = iota

ctx := context.WithValue(r.Context(), RequestIDKey, requestID)

// Later:
id, _ := ctx.Value(RequestIDKey).(string)

Use sparingly — only for cross-cutting values like request ID, user ID, trace span. Don’t pass business data through context; pass it as arguments.

Cancellation in goroutines

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return
        case j, ok := <-jobs:
            if !ok { return }
            process(ctx, j)
        }
    }
}

Always select on <-ctx.Done() in long-lived goroutines. Otherwise: leak.

Detecting cancel during work

For CPU-bound loops:

for i, item := range items {
    if i%1000 == 0 {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
    }
    process(item)
}

Check periodically; not on every iteration (would be too costly).

Shutdown

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()
    
    srv := &http.Server{Addr: ":8080", Handler: mux}
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    
    <-ctx.Done()
    log.Println("shutting down")
    
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    srv.Shutdown(shutdownCtx)
}

SIGTERM → ctx cancels → server shuts down with a 30s grace period. See Production Go Service .

Common mistakes

1. Not calling cancel

ctx, _ := context.WithTimeout(...) // BAD: discarded cancel

Resources leak until timer fires. Always:

ctx, cancel := context.WithTimeout(...)
defer cancel()

2. Storing context in a struct

type Service struct {
    ctx context.Context // BAD
}

Context is per-call, not per-instance. Pass it as the first argument always.

3. context.Background() in hot paths

Always derive from the request’s context. Otherwise: cancellation doesn’t propagate.

4. Context cancel after sleep

time.Sleep(5 * time.Second) // BAD: doesn't honor cancel

Use:

select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
    return ctx.Err()
}

5. Using context.WithValue for everything

Context is for cross-cutting concerns (trace, request ID, user). Not for arguments.

Real-world example

func (h *Handler) HandleEvent(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
    defer cancel()
    
    g, gCtx := errgroup.WithContext(ctx)
    
    g.Go(func() error { return h.persist(gCtx, event) })
    g.Go(func() error { return h.publish(gCtx, event) })
    g.Go(func() error { return h.audit(gCtx, event) })
    
    if err := g.Wait(); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    w.WriteHeader(204)
}

Three concurrent calls; first error cancels rest; 30s ceiling.

Read this next

If you want my Go service template (context-aware, errgroup, graceful shutdown), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .