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.Canceled or context.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 a cancel function 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 on ctx.Done().
  • Whichever goroutine finishes first calls cancel(), and the others receive the signal via ctx.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.NewRequestWithContext attaches the context to the request; when the context expires, http.Client automatically cancels the running request.
  • If it takes over 2 seconds, resp, err := http.DefaultClient.Do(req) returns an err wrapping context.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 WithDeadline when you have a specific time point (e.g., end of day), unlike WithTimeout which is a relative duration from now.
  • Essentially, WithTimeout(ctx, d) is just a thin wrapper around WithDeadline(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 using string directly because it can collide with keys from other packages.
  • WithRequestID and RequestIDFromContext encapsulate 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.WithValue to 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 in main, tests, or top-level initialization.
  • context.TODO() is used when you’re refactoring and unsure which context to pass—functionally identical to Background() 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
}
  • ExecContext accepts 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 *Context suffix).

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.NewServer creates a fake server without needing complex mocks.

9. Common Mistakes

  • Not calling cancel(): always defer cancel() right after calling WithCancel/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 receives ctx from the caller, you lose the ability to cancel/timeout from above—always derive from the received context: context.WithTimeout(ctx, ...), not context.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.

golangcontextconcurrencygoroutinetimeout