A context.Context in Go isn’t a magic wand for managing timeouts; it’s a responsibility passed down through function calls that signals a shared cancellation or deadline.

Let’s see it in action with a simulated service that needs to fetch data from two other services, but we only care about the first one that responds, and we have a total budget of 500 milliseconds.

package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

func simulateServiceCall(ctx context.Context, name string, delay time.Duration, data string) (string, error) {
	select {
	case <-time.After(delay):
		select {
		case <-ctx.Done():
			return "", ctx.Err() // Context was cancelled before we could return data
		default:
			return data, nil // We successfully got the data
		}
	case <-ctx.Done():
		return "", ctx.Err() // Context was cancelled while we were waiting for the delay
	}
}

func main() {
	// Set a deadline for the entire operation
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel() // Ensure cancel is called to release resources

	var wg sync.WaitGroup
	results := make(chan string, 2)
	errs := make(chan error, 2)

	wg.Add(2)

	// Simulate calling Service A
	go func() {
		defer wg.Done()
		data, err := simulateServiceCall(ctx, "Service A", 200*time.Millisecond, "Data from A")
		if err != nil {
			errs <- fmt.Errorf("service A failed: %w", err)
			return
		}
		results <- data
	}()

	// Simulate calling Service B
	go func() {
		defer wg.Done()
		data, err := simulateServiceCall(ctx, "Service B", 700*time.Millisecond, "Data from B")
		if err != nil {
			errs <- fmt.Errorf("service B failed: %w", err)
			return
		}
		results <- data
	}()

	// Wait for either service to finish or the deadline to pass
	var finalResult string
	select {
	case result := <-results:
		finalResult = result
		cancel() // Cancel the context immediately once we have a result
	case err := <-errs:
		fmt.Println("Error encountered:", err)
		cancel() // Cancel the context on error
		return
	case <-ctx.Done():
		fmt.Println("Operation timed out or was cancelled:", ctx.Err())
		return
	}

	// Wait for any remaining goroutines to finish after we've got a result
	wg.Wait()
	close(results)
	close(errs)

	fmt.Println("Received result:", finalResult)
}

This code demonstrates a common pattern: initiating multiple operations concurrently, each with a shared context.Context. When context.WithTimeout is called, it creates a new context that will be automatically cancelled after the specified duration. Crucially, defer cancel() ensures that the context’s resources are cleaned up, even if the function exits early.

The simulateServiceCall function shows how to respect the context. It uses a select statement to listen for both the simulated work (time.After) and the context’s cancellation signal (ctx.Done()). If ctx.Done() receives a signal, it means the parent context was cancelled or timed out, and the function should stop its work and return ctx.Err().

In main, we launch two goroutines to simulate calls to "Service A" and "Service B." Service A is designed to respond within the 500ms deadline (at 200ms), while Service B would exceed it (at 700ms). The outer select statement in main is the key to handling the results. It listens on results, errs, and ctx.Done(). As soon as a result comes back from results, we capture it, immediately call cancel(), and then proceed. Calling cancel() here is vital: it signals to any other goroutines still working (like Service B in this case) that their results are no longer needed, preventing wasted computation and resource usage.

If the ctx.Done() case in the outer select is triggered before any result is received, it means the entire operation, including all concurrent calls, has exceeded the 500ms deadline.

The context.Context interface is deceptively simple, but its power lies in its propagation. A context created with context.WithCancel, context.WithTimeout, or context.WithDeadline creates a hierarchy. When the parent context is cancelled, all derived contexts are also cancelled. This allows for cascading cancellation throughout your application, ensuring that no work continues unnecessarily.

A common pitfall is forgetting to propagate the context. If you have a function that calls another function, and the inner function performs I/O or long-running work, it must accept a context.Context as its first argument and check ctx.Done(). If it doesn’t, it can’t be cancelled from the outside.

When you pass a context down to a goroutine, you are essentially giving that goroutine a leash. That leash can be of a specific length (timeout) or can be cut at any time (cancel). If the goroutine doesn’t check if its leash has been cut, it will just keep running until it finishes, oblivious to the fact that its work is no longer required.

The context.Background() function is typically used for the top-level context, the root of your context tree. It’s never cancelled, has no values, and is used for contexts that are not tied to any specific request or goroutine. context.TODO() is a placeholder for when you’re unsure which context to use or when the context has not yet been implemented. It’s a signal to yourself or others that this needs to be revisited.

The most surprising thing about Go’s context is how it couples cancellation with request-scoped values. While often used for cancellation and deadlines, context.WithValue allows you to pass request-scoped data down the call stack without needing to explicitly pass it as function parameters. This can be incredibly useful for carrying things like request IDs, authentication credentials, or user information that are relevant only for the duration of a single request. However, it’s crucial to use WithValue sparingly and only for truly request-scoped data, as overusing it can lead to less explicit and harder-to-debug code, essentially turning context into a global variable disguised as a parameter.

The next concept you’ll likely encounter is how to properly handle multiple contexts and their cancellation signals, especially when dealing with complex dependencies or fan-out/fan-in patterns.

Want structured learning?

Take the full Golang course →