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 acancel()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
ctxas the first argument, always namedctx: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.Valuefor 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.