Go Context: why you pass it down the chain and why every context needs a timeout
Golang

Go Context: why you pass it down the chain and why every context needs a timeout

context.Context isn't magic, and it isn't the first argument out of habit. It's the mechanism that lets the entire call chain know when to stop. Without it, every cancelled request becomes a leak.

What happens without context

An HTTP handler accepts a request, calls a service, the service hits a repository, the repository talks to the database. The client closes the connection and walks away.

Without ctx: the DB query keeps running, holds a pooled connection, burns CPU. Goroutines hang. Every dropped client adds to the pile.

With ctx: r.Context() signals cancellation, each layer passes it deeper, and a sane DB driver tells the server to cancel the query. Resources free up immediately.

How "cancelling the whole chain" actually works

This is the part people gloss over. There is no broadcast, no kill signal flying around. The mechanism is much simpler.

Context is a tree. Every call to WithCancel, WithTimeout, or WithDeadline creates a child context bound to its parent. Each context exposes a Done() channel that gets closed on cancellation.

Two rules run the whole show:

  1. When a parent's Done() closes, every child's Done() closes too, recursively down the tree.
  2. Any function that accepts a ctx is responsible for listening to ctx.Done() and returning ctx.Err() when it fires.

That's it. Cancellation isn't pushed from the top. It's a subscription from the bottom. The root just closes a channel, and every level decides for itself how to react (usually: bail out with an error).

func Handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // ctx-1: HTTP server cancels this if the client leaves

    ctx, cancel := context.WithTimeout(ctx, 2*time.Second) // ctx-2: child of ctx-1
    defer cancel()

    user, err := service.GetUser(ctx, id) // ctx-2 goes deeper
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
    return s.repo.Find(ctx, id) // same ctx, even deeper
}

func (r *Repo) Find(ctx context.Context, id string) (*User, error) {
    // QueryRowContext listens to ctx.Done() and tells the DB to cancel
    return r.db.QueryRowContext(ctx, "SELECT ...", id).Scan(...)
}

What happens when the client disconnects:

  • HTTP server closes ctx-1.Done()
  • ctx-2 is its child, so its Done() closes automatically
  • QueryRowContext is watching that same Done() and sends a cancel to the DB
  • Find returns an error, GetUser returns an error, Handler returns an error
  • The whole chain unwinds in milliseconds

Who actually does the listening

The standard library and decent drivers handle it for you:

Function How it watches ctx
http.Client.Do aborts read/write when Done() fires
sql.DB.QueryContext sends cancel to the driver, driver to the DB
time.After vs ctx.Done() in select first one to fire wins
io.Copy does not listen; wrap it yourself if needed

For functions you write yourself, you have to wire it up by hand:

// Option 1: select when there is a blocking operation
select {
case <-ctx.Done():
    return ctx.Err()
case res := <-someChan:
    return res, nil
}

// Option 2: periodic check in a long loop
for _, item := range bigSlice {
    if err := ctx.Err(); err != nil {
        return err
    }
    process(item)
}

Rules for passing ctx

  • ctx is always the first parameter of a function
  • never store ctx in a struct, pass it explicitly
  • never pass nil. If you really don't know what to put there, use context.TODO()
  • ctx is a value, copying is cheap
  • don't wrap an existing ctx without a reason. Each WithCancel spawns a watchdog goroutine

Why every context needs a timeout

Say everything is wired up properly: ctx flows all the way to the database, cancellation works. But the client doesn't go anywhere, the database itself just freezes. What now?

You wait. For a long time. Until TCP keepalive gives up or someone kills the process.

That's why every responsibility boundary gets a WithTimeout:

ctx, cancel := context.WithTimeout(parentCtx, 500*time.Millisecond)
defer cancel()

result, err := repo.Find(ctx, id)

What this buys you:

  1. Protection from slow dependencies. DB is stuck, you don't hang forever, you bail at 500ms with a clear error.
  2. Resource release. defer cancel() makes sure the watchdog goroutine doesn't leak even if the call returns before the deadline.
  3. Cascading cancellation in both directions. Parent ctx gets cancelled earlier? Your timer cancels too. Your timer fires? All child contexts cancel.
  4. Backpressure. Under load, short timeouts free the connection pool faster than a queue of stuck requests ever could.
  5. SLA in code. You see WithTimeout(2*time.Second) and you immediately know what the call is budgeted for. No digging through Confluence.

What timeouts to pick

Level Ballpark Reasoning
HTTP handler (overall) 5-10s clients won't wait longer anyway
External API 1-3s plus retry with backoff
DB query 200ms-1s longer means something's wrong with the query
Redis / cache 50-100ms if cache is slow, fall through to source
Inter-service RPC smaller than parent minus network overhead

Core rule: a child timeout must always be smaller than its parent. Otherwise it's useless, the parent fires first.

What not to do

  • Don't ignore cancel(). go vet complains for a reason, it's a leak.
  • Don't dump everything into ctx. WithValue is for request-scoped data (trace id, user id), not for DI or config.
  • Don't wrap WithTimeout(ctx, 30*time.Second) at every level "just in case". If the parent already has a 1s timeout, your 30s is noise.
  • Don't check ctx.Err() on every line with an if. If there's a blocking call that accepts ctx inside, it will bail on its own.
  • Don't piggyback "fire and forget" work on the request ctx. If you need background work to outlive the response, start a fresh ctx from context.Background() with its own timeout. Do not inherit from the request.

TL;DR

Context is about two things: cancellation and deadlines. Pass it through every call, set timeouts at every responsibility boundary, always defer cancel(). Cancellation alone only protects you from a client that left. Timeouts protect you from a dependency that froze. You need both.

Sources

  • pkg.go.dev/context: package docs with the canonical rules (ctx as first param, no nil, no storing in structs, WithValue only for request-scoped data).
  • go.dev/blog/context: the original Go blog post introducing the package, with a full worked HTTP server example.
  • go.dev/blog/context-and-structs: focused follow-up on why ctx should be a function parameter and not a struct field.

Quote of the day:

Великие горести оказываются всегда плодом необузданного корыстолюбия.
By den On May 11, 2026

Leave a reply