DEV Community

MSakai
MSakai

Posted on

The context you passed is not the context that cancels

Two failure modes, opposite in effect, same root cause. Both compile, and both are quiet in development.

Failure one: the work dies with the request

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    order := parse(r)
    save(order)

    go func() {
        h.sendConfirmationEmail(r.Context(), order)   // cancelled the moment Create returns
    }()

    w.WriteHeader(http.StatusCreated)
}
Enter fullscreen mode Exit fullscreen mode

r.Context() is cancelled when the handler returns and the response is written. Your goroutine gets a context that is already dead, or dies milliseconds later. Emails silently don't send — and under local testing, where the goroutine usually wins the race, everything looks fine.

Since Go 1.21 the fix is one call:

ctx := context.WithoutCancel(r.Context())
go h.sendConfirmationEmail(ctx, order)
Enter fullscreen mode Exit fullscreen mode

WithoutCancel keeps the values — trace IDs, request-scoped logger, auth subject — while detaching from the parent's cancellation. Before 1.21 you had to hand-roll a context.Background() and re-attach every value you cared about, which is how those values usually got lost.

Give the detached work its own deadline, because it no longer has one:

ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second)
go func() {
    defer cancel()
    h.sendConfirmationEmail(ctx, order)
}()
Enter fullscreen mode Exit fullscreen mode

Failure two: the cancellation never arrives

func fetchAll(ctx context.Context, ids []string) ([]Item, error) {
    var items []Item
    for _, id := range ids {
        item, err := db.Get(context.Background(), id)   // ignores ctx entirely
        if err != nil {
            return nil, err
        }
        items = append(items, item)
    }
    return items, nil
}
Enter fullscreen mode Exit fullscreen mode

The caller cancels. Nothing stops. The loop runs to completion against a client that hung up, holding a database connection the whole time. Under load this is how a spike in cancelled requests turns into connection pool exhaustion.

The signature accepts a ctx and then ignores it, which is worse than not accepting one — every caller reasonably assumes cancellation works.

Making it visible

go vet catches a lost cancel, but not a lost ctx. Two things that do help:

contextcheck (available via golangci-lint) flags functions that receive a context and then pass context.Background() downstream — exactly failure two.

# .golangci.yml
linters:
  enable:
    - contextcheck
    - noctx        # flags http.NewRequest without a context
Enter fullscreen mode Exit fullscreen mode

A test that asserts cancellation propagates. If it matters, assert it:

func TestFetchAllRespectsCancellation(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel()

    _, err := fetchAll(ctx, []string{"a", "b", "c"})
    if !errors.Is(err, context.Canceled) {
        t.Fatalf("expected context.Canceled, got %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Pre-cancelling before the call is the cheap version and catches most regressions.

The convention that prevents both

A context is scoped to an operation. When the operation's lifetime changes, the context has to change with it — explicitly.

Concretely:

  • Work that must outlive the request: context.WithoutCancel, plus a fresh timeout
  • Work that is part of the request: pass ctx down, unchanged, to every call that accepts one
  • context.Background() appears in main, in tests, and essentially nowhere else

The third rule is the one worth grepping for. Every context.Background() inside a request path is either failure two, or a comment waiting to be written explaining why it isn't.


These posts come out of material I build for my Udemy courses — 25 of them now, mostly drill-based, across Go, Python, TypeScript, testing and Three.js. If this was useful, the full list is at udemy-c1f90.web.app. The links on that page carry a coupon I refresh each month, which usually lands around half the list price.

Top comments (0)