Context in Go: Cancellation, Deadlines and Request Propagation
Understand Go's context.Context: cancellation, deadlines, timeouts and request-scoped propagation, with practical HTTP and database examples.
The problem
A typical backend handler doesn't do one thing — it calls a database, maybe an internal service, maybe a cache, and returns a response. If the client disconnects, or a request-level timeout fires, none of that in-flight work stops on its own. The goroutine handling the request keeps running, the database driver keeps waiting on a query, and the connection pool stays occupied for a request nobody is waiting on anymore.
context.Context exists to solve exactly this: propagating a single signal —
"stop what you're doing" — through a call chain that may span goroutines,
network calls, and library boundaries, without every function needing to know
who triggered the cancellation or why.
Why it matters in production
Without context propagation, a slow downstream dependency doesn't just slow down one request — it ties up resources for requests that no longer matter:
- HTTP handlers that don't cancel their downstream calls keep goroutines, memory, and database connections alive for clients that already gave up.
- A single slow query without a deadline can exhaust a connection pool, which then blocks unrelated, healthy requests.
- Retrying or fanning out to multiple services without a shared deadline means a single slow leg can make the total request take arbitrarily long.
context.Context is how Go's standard library — net/http, database/sql,
net, and most third-party clients — agrees on how to propagate that signal
consistently.
How context actually works
A Context is an immutable, tree-shaped value. Deriving a new context from a
parent (WithCancel, WithTimeout, WithDeadline, WithValue) creates a
child node; canceling a parent cancels all of its children, but canceling a
child never affects its parent.
Three things matter for backend work:
Done()returns a channel that's closed when the context is canceled or its deadline passes. Code that can block should select on it.Err()tells you why it was canceled:context.Canceledorcontext.DeadlineExceeded.Value(key)carries request-scoped data — a request ID, a trace span — not application configuration or optional parameters.
WithCancel
Use WithCancel when you want to stop work explicitly, based on something
happening in your own code — for example, stopping a group of workers as soon
as one of them fails.
func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // always release resources associated with the context
results := make(chan Result, len(urls))
errs := make(chan error, 1)
for _, u := range urls {
go func(u string) {
r, err := fetchOne(ctx, u)
if err != nil {
select {
case errs <- err:
cancel() // stop the other in-flight fetches
default:
}
return
}
results <- r
}(u)
}
var out []Result
for range urls {
select {
case r := <-results:
out = append(out, r)
case err := <-errs:
return nil, err
}
}
return out, nil
}The defer cancel() is not optional cleanup — it's how the context and any
timers or goroutines associated with it get released even on the success
path. go vet will flag a context.WithCancel whose cancel function is
never called on any path.
WithTimeout and WithDeadline
WithTimeout(ctx, d) is WithDeadline(ctx, time.Now().Add(d)) — use whichever
reads more clearly at the call site. Deadlines compose: if a parent context
already has a deadline sooner than the one you request, the shorter deadline
wins.
func (s *OrderService) GetOrder(ctx context.Context, id string) (*Order, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
order, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("get order %s: repository timed out: %w", id, err)
}
return nil, fmt.Errorf("get order %s: %w", id, err)
}
return order, nil
}This is the pattern for any single unit of work with a bounded acceptable latency: a downstream HTTP call, a query, a cache lookup. Set the timeout where the call happens, not several layers up.
Request propagation in an HTTP server
net/http gives every incoming request a context that's canceled when the
client disconnects or the underlying connection closes:
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
order, err := h.service.GetOrder(ctx, chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(order)
}The rule that keeps this reliable across a codebase: always pass ctx as
the first parameter, and always pass the one you were given — don't
silently swap in context.Background() inside a function because it's
convenient. Doing that severs the cancellation chain, and the caller's
timeout stops applying to anything below that point.
Propagating through database calls
database/sql, pgx, and most Go database drivers accept a context on every
query method and will cancel the underlying query when it's done:
func (r *PostgresOrderRepository) FindByID(ctx context.Context, id string) (*Order, error) {
const query = `
SELECT id, customer_id, status, total_cents, created_at
FROM orders
WHERE id = $1
`
var o Order
err := r.db.QueryRowContext(ctx, query, id).Scan(
&o.ID, &o.CustomerID, &o.Status, &o.TotalCents, &o.CreatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrOrderNotFound
}
if err != nil {
return nil, fmt.Errorf("query order: %w", err)
}
return &o, nil
}Because the context flows from the HTTP handler through the service and into
QueryRowContext, a client disconnect or handler-level timeout cancels the
query on the connection — the database driver sends the cancellation to
PostgreSQL instead of leaving the query running while nothing waits on it.
context.Value: what it's for, and what it isn't
context.Value is for data that's scoped to a single request and needed
across API boundaries you don't control — a request ID for logging, a trace
span, an authenticated user extracted by middleware. It is not a substitute
for function parameters.
type contextKey string
const requestIDKey contextKey = "requestID"
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}Use an unexported concrete type for the key (never a plain string) so
values set by one package can't collide with, or be read by, an unrelated
package that happens to use the same string.
If a value is required for a function to behave correctly — a user ID, a
tenant ID, a config flag — pass it as an explicit parameter. Reserve
context.Value for cross-cutting, optional metadata that most callers never
need to inspect directly.
Production considerations
Set timeouts at the edge, not just at the leaf. An HTTP server should
itself have ReadTimeout / WriteTimeout / IdleTimeout configured, and a
top-level per-request deadline is often worth setting in middleware so that
every handler has a hard ceiling regardless of what it calls:
func Timeout(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}Distinguish context.Canceled from context.DeadlineExceeded. The first
usually means the client went away — log it at a low level, don't page
anyone. The second means your system was too slow — that's worth alerting on.
Don't store contexts in structs. A Context is meant to flow through call
chains as a parameter, not to be held as long-lived state on a struct field.
Doing so almost always means the context outlives the request it represents,
and cancellation stops working the way callers expect.
Common mistakes
- Using
context.Background()deep in a call stack instead of threading through the context you were given, which breaks cancellation for everything downstream of that point. - Forgetting to call
cancel()returned byWithCancel/WithTimeout/WithDeadline. Even after the context is no longer needed, the associated timer keeps running untilcancelis called or the deadline passes —defer cancel()immediately after creation avoids this. - Ignoring
ctx.Done()in long-running loops. A worker processing a batch or streaming rows should checkctx.Err()periodically, not just at the start. - Passing
nilinstead of a context. Every standard library API that accepts a context requires a non-nil one; usecontext.Background()orcontext.TODO()explicitly if you have no better context available.
Testing context-aware code
Table-driven tests can exercise both the deadline and cancellation paths directly, without needing real slow dependencies:
func TestGetOrder_DeadlineExceeded(t *testing.T) {
repo := &slowRepoStub{delay: 50 * time.Millisecond}
svc := NewOrderService(repo)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, err := svc.GetOrder(ctx, "order_123")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
}
}A stub repository that sleeps longer than the test's timeout is enough to
verify that a service correctly surfaces context.DeadlineExceeded rather
than hanging or returning a generic error.
Summary
context.Context is Go's mechanism for propagating cancellation, deadlines,
and request-scoped values through a call chain. In backend code, the
practical rules are: thread the context you're given through every function
that can block, set timeouts close to the operation they bound, always call
the cancel function you're handed, and reserve context.Value for optional
cross-cutting metadata rather than required parameters. Getting this right is
what keeps a slow dependency from turning into an outage.
funcRelated()[]Article
Advanced Go Concurrency: Worker Pools, Backpressure and Bounded Concurrency
Why unbounded goroutines don't scale: semaphore-based bounded concurrency, backpressure, errgroup, goroutine leaks, and a real benchmark comparing the two.
Building Background Workers in Go with Goroutines and Channels
A production-oriented worker pool in Go: bounded concurrency, graceful shutdown, panic recovery, and why 'go process(job) for every job' is dangerous.
Graceful Shutdown in Go HTTP Servers
Implement graceful shutdown for Go HTTP servers: handling SIGTERM and SIGINT, draining active requests, shutdown timeouts, and Kubernetes readiness.