Context in Go: timeout, cancel, and value passing
1. Overview
When writing a Go service that handles multiple goroutines, you need a unified way to tell child goroutines: “stop, I don’t need the result anymore” or “run for only 2 seconds then give up”. context.Context was created to solve exactly that problem.
This article walks through three main things you do with context: manual cancellation, setting timeout/deadline, and passing request-scoped values through layers of function calls. After reading, you’ll be able to write HTTP handlers, database calls, and child goroutine functions that know when to stop gracefully, without leaking goroutines.
2. What is Context and Why You Need It
context.Context is an interface that describes the “context” of a processing stream (usually a request). It carries:
- Cancel signal.
- Deadline/timeout.
- A few small key-value pairs attached to the request.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Done()returns a channel that closes when the context is canceled or expires.Err()tells you the reason:context.Canceledorcontext.DeadlineExceeded.Value()retrieves a value attached to the context.
Note: Context should not be used to pass arbitrary optional parameters. It should only carry control signals and request-scoped data like request ID, current user—not configuration or critical business objects.
3. Manual Cancellation with WithCancel
Use this when you want to decide when to cancel; for example, when one of several goroutines gets a result, cancel the others.
func fetchFromMultipleSources(ctx context.Context) (string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // always call cancel to free resources
resultCh := make(chan string, 1)
for _, src := range []string{"cache", "db", "api"} {
go func(source string) {
select {
case <-time.After(100 * time.Millisecond):
resultCh <- source
cancel() // got a result, cancel the other sources
case <-ctx.Done():
return // cancelled, exit early
}
}(src)
}
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
context.WithCancel(ctx)creates a child context and acancelfunction to cancel it.defer cancel()is mandatory: if you don’t call it, the child context “hangs” until the parent is cancelled, leaking the goroutine waiting onctx.Done().- Whichever goroutine finishes first calls
cancel(), and the others receive the signal viactx.Done()and stop.
Common mistake: forgetting defer cancel(). If your function returns early (e.g., due to an error) without cancelling the context, child goroutines stay alive until garbage collection—that’s a leak.
4. Timeout and Deadline with WithTimeout, WithDeadline
4.1. WithTimeout for Relative Time Limits
func callPaymentService(ctx context.Context, orderID string) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://payment.local/orders/"+orderID, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // may be context.DeadlineExceeded
}
defer resp.Body.Close()
return nil
}
context.WithTimeout(ctx, 2*time.Second)automatically cancels the context after 2 seconds.http.NewRequestWithContextattaches the context to the request; when the context expires,http.Clientautomatically cancels the running request.- If it takes over 2 seconds,
resp, err := http.DefaultClient.Do(req)returns anerrwrappingcontext.DeadlineExceeded.
Checking for a timeout error:
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("payment service timeout")
}
return err
}
4.2. WithDeadline for Absolute Time Points
func processBeforeMidnight(ctx context.Context) error {
deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.Local)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
select {
case <-time.After(5 * time.Hour):
return errors.New("processing completed")
case <-ctx.Done():
return ctx.Err()
}
}
- Use
WithDeadlinewhen you have a specific time point (e.g., end of day), unlikeWithTimeoutwhich is a relative duration from now. - Essentially,
WithTimeout(ctx, d)is just a thin wrapper aroundWithDeadline(ctx, time.Now().Add(d)).
5. Passing Values Through Context with WithValue
Use this for data attached to a request, not business parameters.
type requestIDKey struct{}
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
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
ctx := WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func orderHandler(w http.ResponseWriter, r *http.Request) {
if id, ok := RequestIDFromContext(r.Context()); ok {
log.Printf("[%s] processing order", id)
}
}
- Use
type requestIDKey struct{}as a distinct key—avoid usingstringdirectly because it can collide with keys from other packages. WithRequestIDandRequestIDFromContextencapsulate the set/get logic; other packages don’t need to know the actual key.r.WithContext(ctx)creates a new request carrying the context with attached values, passing it along the middleware chain.
Note: Don’t use
context.WithValueto pass*sql.DB, config, or anything your function needs to work correctly. Those should be explicit parameters or struct fields. Context values are only suitable for “nice to have” data like request ID, trace ID, or logged-in user.
6. Context.TODO and context.Background
func main() {
ctx := context.Background() // root context, never cancelled
run(ctx)
}
func legacyFunctionBeingRefactored() {
ctx := context.TODO() // placeholder, will replace with real context later
_ = ctx
}
context.Background()is the root context, used inmain, tests, or top-level initialization.context.TODO()is used when you’re refactoring and unsure which context to pass—functionally identical toBackground()but signals your intent to readers and linting tools.
7. Propagating Context Through Multiple Call Layers
Convention: ctx context.Context is always the first parameter.
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
if err := s.validate(ctx, order); err != nil {
return err
}
return s.repo.Save(ctx, order)
}
func (r *OrderRepository) Save(ctx context.Context, order Order) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO orders (id, total) VALUES (?, ?)", order.ID, order.Total)
return err
}
ExecContextaccepts a context; if the HTTP request above is cancelled (client closes the connection), the DB driver cancels the running query—avoiding wasted connections.- Rule: any function that does I/O (HTTP, DB, gRPC) should accept a context and pass it to APIs that support it (those with
*Contextsuffix).
8. Testing Behavior with Tests
func TestFetchFromMultipleSources_ReturnsFastestSource(t *testing.T) {
ctx := context.Background()
result, err := fetchFromMultipleSources(ctx)
require.NoError(t, err)
assert.Contains(t, []string{"cache", "db", "api"}, result)
}
func TestCallPaymentService_TimesOutWhenSlow(t *testing.T) {
slowServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
}))
defer slowServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
_, err := http.DefaultClient.Do(req)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
<!-- go.mod: add assertion library (not actual XML, just a note) -->
go get github.com/stretchr/testify
- The first test uses
context.Background()because you don’t need timeout control in typical unit tests. - The second test deliberately sets a timeout of 500ms shorter than the server’s processing time (3s) to confirm the error is indeed
context.DeadlineExceeded. httptest.NewServercreates a fake server without needing complex mocks.
9. Common Mistakes
- Not calling
cancel(): alwaysdefer cancel()right after callingWithCancel/WithTimeout/WithDeadline, even if the context will expire on its own—Go vet will warn you if you forget. - Storing context in a struct field: context should be passed as a function parameter, not stored as a struct field (except special cases like
http.Request). Storing it in a field easily leads to stale, old contexts. - Using context to pass required parameters: if your function doesn’t work correctly without a value, pass it as a regular parameter, not hidden in context.
- Creating a new context instead of deriving from the parent: if you call
context.Background()inside a function that receivesctxfrom the caller, you lose the ability to cancel/timeout from above—always derive from the received context:context.WithTimeout(ctx, ...), notcontext.WithTimeout(context.Background(), ...).
When you don’t need context: for pure computational functions with no I/O and no goroutines, you don’t need context as a parameter—adding it just clutters the code.
10. Conclusion
Context solves three core tasks in Go concurrency: signaling cancellation, enforcing time limits, and carrying a few values across call layers. The safest rule is to always accept ctx as the first parameter, always defer cancel(), and use WithValue only for truly ancillary request-scoped data. Master these principles, and you’ll write Go services that don’t leak goroutines and respond correctly when clients timeout or cancel requests.