Skip to content
Go

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.

GoBackend.dev10 min read
GoContextConcurrency

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:

  1. Done() returns a channel that's closed when the context is canceled or its deadline passes. Code that can block should select on it.
  2. Err() tells you why it was canceled: context.Canceled or context.DeadlineExceeded.
  3. 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 by WithCancel / WithTimeout / WithDeadline. Even after the context is no longer needed, the associated timer keeps running until cancel is 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 check ctx.Err() periodically, not just at the start.
  • Passing nil instead of a context. Every standard library API that accepts a context requires a non-nil one; use context.Background() or context.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.