The context Package

intermediate go concurrency

context is the standard way to carry cancellation signals, deadlines, and request-scoped values down through a chain of function calls and goroutines.

In simple language, a context is like a “stop” signal we hand to every function involved in a request. When the user closes the browser tab or a timeout hits, we flip the signal and everyone downstream knows to give up and clean up.

Why we need it

Imagine an HTTP request that triggers a database query, which triggers another service call. If the client disconnects, all that work is wasted. Without context, those goroutines keep grinding away. With context, the cancellation flows down and they all stop. It prevents leaked goroutines and wasted work.

Creating contexts

Every context tree starts from a root. context.Background() is the empty root we use at the top of main, in tests, and at request entry points.

From there, we derive child contexts that add behavior:

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always call cancel to free resources

ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2() // ctx2 auto-cancels after 2 seconds

ctx3, cancel3 := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel3() // ctx3 cancels at a specific wall-clock time
  • WithCancel — gives us a cancel() function to stop it manually.
  • WithTimeout — auto-cancels after a duration.
  • WithDeadline — auto-cancels at a specific time.

Always defer cancel(). Even if the context finishes on its own, calling cancel releases resources. Forgetting it leaks.

Listening for cancellation

Two methods do the work. ctx.Done() returns a channel that closes when the context is cancelled. ctx.Err() tells us why (context.Canceled or context.DeadlineExceeded).

select {
case <-ctx.Done():
	fmt.Println("cancelled:", ctx.Err()) // gives the reason
	return
case result := <-work:
	fmt.Println("got:", result)
}

Honoring context in an HTTP handler

Every *http.Request already carries a context tied to the connection. If the client disconnects, that context cancels automatically. We just have to listen.

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // cancels if the client goes away

	select {
	case <-time.After(3 * time.Second): // pretend this is slow work
		fmt.Fprintln(w, "done!")
	case <-ctx.Done():
		// client disconnected or timed out — bail out, don't waste work
		http.Error(w, ctx.Err().Error(), http.StatusRequestTimeout)
	}
}

If the user closes the tab after one second, ctx.Done() fires and we stop instead of finishing the full three seconds of work.

The convention and the rules

Context has a few firm conventions that interviewers check for:

  • Pass ctx as the first argument, always named ctx: func DoThing(ctx context.Context, arg string).
  • Don’t store context in a struct. Pass it explicitly through function calls. A context is per-request and short-lived; a struct usually outlives the request.
  • Don’t use context.Value for passing regular parameters. It’s only for request-scoped data that crosses API boundaries — things like a request ID or auth token — not for function arguments. Values are untyped (interface{}), so abusing them hides your function’s real inputs.
ctx := context.WithValue(context.Background(), "requestID", "abc123")
id := ctx.Value("requestID") // request-scoped metadata, NOT business params

Interview soundbite

context propagates cancellation, deadlines, and request-scoped values down a call chain. We start from context.Background(), derive children with WithCancel/WithTimeout/WithDeadline, always defer cancel(), and listen on ctx.Done() checking ctx.Err() for the reason. Conventions: ctx is the first arg, never store it in a struct, and Value is only for request-scoped metadata — never for passing normal parameters.