ToolPopToolPop
Go · Lesson 13 of 22

context.Context, cancellation, and deadlines

8 min readUpdated 25 Jun 2026

context.Context is the single most important type in production Go. Every HTTP handler, every database call, every outbound API request, every goroutine that does work on behalf of a request takes a ctx as its first parameter. If you see a Go codebase where it does not, you are looking at code written by someone who has never been paged at 3 AM about a stuck goroutine.

This lesson is the senior view: what context actually is, how it propagates, when to use which constructor, and the subtle traps interviewers love.

What context.Context actually is

context.Context is an interface with four methods: Deadline(), Done(), Err(), and Value(). That is the entire contract. Everything else is built on top of these.

go
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

The Done() channel is the cancellation signal. When the context is cancelled or its deadline passes, the channel is closed, which means every goroutine reading from it unblocks at the same time. That is the entire cancellation mechanism. The rest is plumbing.

The four (and a half) constructors

Memorise these. Every interview eventually asks.

go
ctx := context.Background()                            // root, never cancelled
ctx := context.TODO()                                  // placeholder, refactor later
ctx, cancel := context.WithCancel(parent)              // manual cancel
ctx, cancel := context.WithTimeout(parent, 5*time.Second)  // auto-cancel after duration
ctx, cancel := context.WithDeadline(parent, t)         // auto-cancel at absolute time
ctx := context.WithValue(parent, key, value)           // attach request-scoped data

Background() is the root. Use it in main, in tests, and at the top of long-lived services. TODO() is functionally identical but signals "I have not figured out the right context to pass here". Use it during refactors, then come back and replace.

Senior rule: every function that does I/O or can take >100ms takes a context.Context as the first parameter. No exceptions in production code. Database queries, HTTP calls, gRPC calls, file reads on slow disks, channel sends in a fan-out, all take ctx.

The cancellation cascade

When you cancel a context, every context derived from it is also cancelled. Recursively. This is the entire point.

Diagram
rendering diagram...
One request, one context, cancellation cascades to every child goroutine

A real Swiggy order-status handler might fan out to inventory, payment, and delivery services in parallel. If the user closes their browser, the HTTP server cancels the request context. That cancel cascades into all three outbound calls, which cancel into their database queries, which release their connections back to the pool. The whole tree collapses cleanly in milliseconds.

Propagating context through goroutines

The pattern you write a hundred times.

go
func handle(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // always release resources even on success
 
    results := make(chan result, 2)
    go fetchA(ctx, results)
    go fetchB(ctx, results)
 
    select {
    case r := <-results:
        return r.err
    case <-ctx.Done():
        return ctx.Err() // returns context.DeadlineExceeded or context.Canceled
    }
}

Two things to notice. First, defer cancel() is non-optional. Even when the function succeeds, you must call cancel to release the timer the context is holding. Forget it and you leak resources. Go's vet tool catches the obvious cases but not all of them.

Second, ctx.Err() returns one of two errors: context.Canceled (someone called cancel) or context.DeadlineExceeded (the deadline passed). Log which one happened, it tells you whether you have slow downstream services or impatient upstream clients.

select with ctx.Done()

The canonical cancellation-aware operation.

go
select {
case <-ctx.Done():
    return ctx.Err()
case result := <-workCh:
    return process(result)
case <-time.After(2 * time.Second):
    return errors.New("local op timed out")
}

If the parent cancels, you exit immediately. If the work completes, you process. If neither happens in 2 seconds, you bail. This is the pattern for every blocking operation that does not natively accept a context.

context.WithValue: the most misused API in Go

context.WithValue attaches data to the context tree. It is for request-scoped values like a trace ID, an authenticated user ID, or a request logger. That is it. That is the entire intended use case.

go
type ctxKey string
const userKey ctxKey = "user"
 
ctx = context.WithValue(ctx, userKey, currentUser)
// downstream:
u, _ := ctx.Value(userKey).(*User)

Interview trap: "Can I use context to pass arguments to my function?" The wrong answer is yes. The right answer is no. Function arguments belong in the function signature where the compiler can check them. Context values are untyped (any), require a type assertion to use, and turn your codebase into a runtime treasure hunt.

Use a custom unexported type as the key to avoid collisions between packages. Never use a plain string. The Go standard library does this everywhere, including the net/http package's value for the request body.

What you actually do in production

Three rules and you are done.

One: take ctx as the first parameter of every function that does anything slow.

Two: pass it down. Never call a context-aware function with context.Background() when you have a real context available. That severs the cancellation chain and is one of the most common bugs in real Go services.

Three: defer cancel() after every WithCancel, WithTimeout, WithDeadline. Always.

Context primitives

context.Context
Interface carrying cancellation signals, deadlines, and request-scoped values across API boundaries.
Done() channel
Closes when the context is cancelled or its deadline passes. The fundamental cancellation signal.
WithCancel
Creates a derived context that can be manually cancelled. Returns the new context and a cancel function.
WithTimeout
Creates a derived context that auto-cancels after the given duration.
context.Canceled
Error returned by ctx.Err() when someone called the cancel function.
context.DeadlineExceeded
Error returned by ctx.Err() when the deadline passed before completion.

Senior rule: every function that does I/O or can take >100ms takes a context.Context as the first parameter. No exceptions in production code.

Common questions

Q.Why must context be the first parameter of a function?
A.Go convention, but the reason matters: context propagates cancellation through your entire call chain. Putting it first makes it impossible to accidentally pass nil or forget it. The Go team enforces this in the standard library and every major Go codebase.
Q.When should I use context.WithValue?
A.Only for request-scoped values that need to traverse API boundaries (request ID, auth user, trace ID). Never for optional function arguments. The values should be process-wide identifiers, not business logic.
Chai0/1 done

Watching quietly. Tap me if you want a tip.

Go Playground

Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.

Try this (0 of 1 done)

  1. 1

    Predict the output.

    hint

    The 100ms deadline fires before the 500ms work completes.

    show answer
    // already in the editor