Context and Cancellation Pattern in Go: Graceful Shutdowns and Timeouts
A user clicks "Cancel" on a long-running search. An API request takes too long. The server is shutting down. In all these cases, you need to stop ongoing work cleanly, without leaking goroutines or leaving resources in bad states.
Go's
context package is the standard way to handle cancellation, timeouts, and request-scoped values. It's so important that it's the first parameter in most Go APIs. Master this pattern, and you'll build robust, well-behaved applications.The Real World Parallel: A Restaurant Kitchen
Think of it like this: A customer orders a complex dish. The kitchen starts preparing multiple components simultaneously: one chef makes the sauce, another prepares the vegetables, a third handles the protein. Then the customer cancels their order. The kitchen needs to stop all related work immediately. Each chef checks periodically if the order is still active. When cancelled, they all stop, clean up their stations, and move to the next order. This is context cancellation.

Concurrency pattern diagram 1
Why Context Matters
Without context, you can't:
- Stop a goroutine from outside
- Set timeouts on operations
- Cancel multiple goroutines at once
- Pass request-scoped data safely
go// Filename: why_context.go package main import ( "context" "fmt" "time" ) // WITHOUT context - cannot be cancelled! func searchWithoutContext(query string) []string { time.Sleep(5 * time.Second) // User waiting... return []string{"result1", "result2"} } // WITH context - cancellable, with timeout func searchWithContext(ctx context.Context, query string) ([]string, error) { resultCh := make(chan []string, 1) go func() { time.Sleep(5 * time.Second) // Simulate search resultCh <- []string{"result1", "result2"} }() select { case results := <-resultCh: return results, nil case <-ctx.Done(): return nil, ctx.Err() // Cancelled or timed out! } } func main() { // With 2 second timeout - search will be cancelled ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() fmt.Println("Starting search with 2s timeout...") results, err := searchWithContext(ctx, "golang") if err != nil { fmt.Printf("Search cancelled: %v\n", err) } else { fmt.Printf("Results: %v\n", results) } }
Expected Output:
Starting search with 2s timeout... Search cancelled: context deadline exceeded
Context Types
go// Filename: context_types.go package main import ( "context" "fmt" "time" ) func main() { // 1. Background context - the root, never cancelled bg := context.Background() fmt.Println("1. Background context - root of all contexts") // 2. TODO context - placeholder when unsure what context to use todo := context.TODO() fmt.Println("2. TODO context - placeholder for future") // 3. WithCancel - manually cancellable ctxCancel, cancel := context.WithCancel(bg) fmt.Printf("3. WithCancel - cancellable: done=%v\n", ctxCancel.Err()) cancel() fmt.Printf(" After cancel: done=%v\n", ctxCancel.Err()) // 4. WithTimeout - auto-cancels after duration ctxTimeout, cancelTimeout := context.WithTimeout(bg, 100*time.Millisecond) defer cancelTimeout() fmt.Println("4. WithTimeout - cancels after 100ms") time.Sleep(150 * time.Millisecond) fmt.Printf(" After 150ms: done=%v\n", ctxTimeout.Err()) // 5. WithDeadline - auto-cancels at specific time deadline := time.Now().Add(50 * time.Millisecond) ctxDeadline, cancelDeadline := context.WithDeadline(bg, deadline) defer cancelDeadline() fmt.Println("5. WithDeadline - cancels at specific time") time.Sleep(100 * time.Millisecond) fmt.Printf(" After deadline: done=%v\n", ctxDeadline.Err()) // 6. WithValue - carries request-scoped data type keyType string const userKey keyType = "user" ctxValue := context.WithValue(bg, userKey, "alice") fmt.Printf("6. WithValue - carries data: user=%v\n", ctxValue.Value(userKey)) }
Real World Example 1: HTTP Server with Graceful Shutdown
go// Filename: graceful_shutdown.go package main import ( "context" "fmt" "net/http" "os" "os/signal" "sync" "syscall" "time" ) // Server with graceful shutdown type Server struct { httpServer *http.Server wg sync.WaitGroup } func NewServer(addr string) *Server { mux := http.NewServeMux() s := &Server{ httpServer: &http.Server{ Addr: addr, Handler: mux, }, } // Long-running endpoint mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { s.wg.Add(1) defer s.wg.Done() ctx := r.Context() // Simulate long work select { case <-time.After(10 * time.Second): fmt.Fprintln(w, "Work completed!") case <-ctx.Done(): fmt.Printf("Request cancelled: %v\n", ctx.Err()) http.Error(w, "Request cancelled", http.StatusServiceUnavailable) } }) // Health check mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) }) return s } func (s *Server) Start() error { fmt.Printf("Server starting on %s\n", s.httpServer.Addr) return s.httpServer.ListenAndServe() } func (s *Server) Shutdown(ctx context.Context) error { fmt.Println("Shutting down server...") // Stop accepting new connections if err := s.httpServer.Shutdown(ctx); err != nil { return err } // Wait for in-flight requests done := make(chan struct{}) go func() { s.wg.Wait() close(done) }() select { case <-done: fmt.Println("All requests completed") return nil case <-ctx.Done(): return ctx.Err() } } func main() { server := NewServer(":8080") // Start server in goroutine go func() { if err := server.Start(); err != http.ErrServerClosed { fmt.Printf("Server error: %v\n", err) } }() // Wait for shutdown signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit fmt.Println("\nReceived shutdown signal") // Graceful shutdown with 30 second timeout ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { fmt.Printf("Shutdown error: %v\n", err) } fmt.Println("Server stopped gracefully") }
Real World Example 2: Database Operations with Timeout
go// Filename: db_with_context.go package main import ( "context" "fmt" "math/rand" "time" ) // User represents a database user type User struct { ID int Name string Email string } // Database simulates a database connection type Database struct{} // QueryUser simulates a database query with context func (db *Database) QueryUser(ctx context.Context, userID int) (*User, error) { // Simulate query with variable latency queryTime := time.Duration(100+rand.Intn(400)) * time.Millisecond select { case <-time.After(queryTime): // Query completed return &User{ ID: userID, Name: fmt.Sprintf("User %d", userID), Email: fmt.Sprintf("user%d@example.com", userID), }, nil case <-ctx.Done(): // Context cancelled or timed out return nil, fmt.Errorf("query cancelled: %w", ctx.Err()) } } // QueryUsers simulates fetching multiple users func (db *Database) QueryUsers(ctx context.Context, userIDs []int) ([]*User, error) { results := make([]*User, 0, len(userIDs)) for _, id := range userIDs { // Check context before each query if ctx.Err() != nil { return results, fmt.Errorf("stopped after %d users: %w", len(results), ctx.Err()) } user, err := db.QueryUser(ctx, id) if err != nil { return results, err } results = append(results, user) } return results, nil } // UserService provides user-related operations type UserService struct { db *Database } // GetUserWithTimeout gets a user with a timeout func (s *UserService) GetUserWithTimeout(userID int, timeout time.Duration) (*User, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() return s.db.QueryUser(ctx, userID) } // GetUsersWithDeadline gets users that must complete by deadline func (s *UserService) GetUsersWithDeadline(userIDs []int, deadline time.Time) ([]*User, error) { ctx, cancel := context.WithDeadline(context.Background(), deadline) defer cancel() return s.db.QueryUsers(ctx, userIDs) } func main() { rand.Seed(time.Now().UnixNano()) service := &UserService{db: &Database{}} fmt.Println("🗄️ Database Operations with Context") fmt.Println("=====================================\n") // Example 1: Single query with timeout fmt.Println("1. Single query with 200ms timeout:") for i := 0; i < 3; i++ { user, err := service.GetUserWithTimeout(1, 200*time.Millisecond) if err != nil { fmt.Printf(" Attempt %d: ❌ %v\n", i+1, err) } else { fmt.Printf(" Attempt %d: ✅ Got %s\n", i+1, user.Name) } } // Example 2: Multiple queries with deadline fmt.Println("\n2. Multiple queries with 500ms deadline:") deadline := time.Now().Add(500 * time.Millisecond) userIDs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} users, err := service.GetUsersWithDeadline(userIDs, deadline) if err != nil { fmt.Printf(" ❌ Error: %v\n", err) } fmt.Printf(" ✅ Retrieved %d/%d users before deadline\n", len(users), len(userIDs)) // Example 3: Cancellable query fmt.Println("\n3. Cancellable query:") ctx, cancel := context.WithCancel(context.Background()) // Cancel after 100ms go func() { time.Sleep(100 * time.Millisecond) fmt.Println(" Cancelling query...") cancel() }() _, err = service.db.QueryUser(ctx, 1) if err != nil { fmt.Printf(" ❌ Query cancelled: %v\n", err) } }
Real World Example 3: Parallel Operations with Shared Cancellation
go// Filename: parallel_with_cancellation.go package main import ( "context" "fmt" "math/rand" "sync" "time" ) // SearchResult from a single source type SearchResult struct { Source string Results []string Error error } // searchSource simulates searching a single source func searchSource(ctx context.Context, source string) SearchResult { latency := time.Duration(100+rand.Intn(300)) * time.Millisecond select { case <-time.After(latency): // Simulate some results return SearchResult{ Source: source, Results: []string{fmt.Sprintf("result from %s", source)}, } case <-ctx.Done(): return SearchResult{ Source: source, Error: ctx.Err(), } } } // SearchAll searches all sources in parallel with shared cancellation func SearchAll(ctx context.Context, query string, sources []string) []SearchResult { results := make([]SearchResult, len(sources)) var wg sync.WaitGroup for i, source := range sources { wg.Add(1) go func(index int, src string) { defer wg.Done() results[index] = searchSource(ctx, src) }(i, source) } wg.Wait() return results } // SearchFirst returns as soon as one source responds func SearchFirst(ctx context.Context, query string, sources []string) (*SearchResult, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() // Cancel remaining searches when we return resultCh := make(chan SearchResult, len(sources)) for _, source := range sources { go func(src string) { result := searchSource(ctx, src) select { case resultCh <- result: case <-ctx.Done(): } }(source) } // Return first successful result for i := 0; i < len(sources); i++ { result := <-resultCh if result.Error == nil { return &result, nil } } return nil, fmt.Errorf("all sources failed") } // SearchWithQuorum returns when N sources respond func SearchWithQuorum(ctx context.Context, query string, sources []string, quorum int) ([]SearchResult, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() resultCh := make(chan SearchResult, len(sources)) for _, source := range sources { go func(src string) { result := searchSource(ctx, src) select { case resultCh <- result: case <-ctx.Done(): } }(source) } var results []SearchResult for i := 0; i < len(sources) && len(results) < quorum; i++ { select { case result := <-resultCh: if result.Error == nil { results = append(results, result) } case <-ctx.Done(): return results, ctx.Err() } } if len(results) < quorum { return results, fmt.Errorf("only got %d/%d results", len(results), quorum) } return results, nil } func main() { rand.Seed(time.Now().UnixNano()) sources := []string{"Google", "Bing", "DuckDuckGo", "Yahoo", "Yandex"} fmt.Println("🔍 Parallel Search with Cancellation") fmt.Println("=====================================\n") // Example 1: Search all with timeout fmt.Println("1. Search all sources (500ms timeout):") ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) results := SearchAll(ctx, "golang", sources) cancel() for _, r := range results { if r.Error != nil { fmt.Printf(" ❌ %s: %v\n", r.Source, r.Error) } else { fmt.Printf(" ✅ %s: %v\n", r.Source, r.Results) } } // Example 2: First response wins fmt.Println("\n2. First response wins:") ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second) first, err := SearchFirst(ctx, "golang", sources) cancel() if err != nil { fmt.Printf(" ❌ Error: %v\n", err) } else { fmt.Printf(" ✅ First response from %s: %v\n", first.Source, first.Results) } // Example 3: Quorum (3 of 5) fmt.Println("\n3. Quorum search (need 3 of 5):") ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second) quorumResults, err := SearchWithQuorum(ctx, "golang", sources, 3) cancel() if err != nil { fmt.Printf(" ❌ Error: %v\n", err) } fmt.Printf(" ✅ Got %d results:\n", len(quorumResults)) for _, r := range quorumResults { fmt.Printf(" - %s\n", r.Source) } }
Context Best Practices
go// Filename: context_best_practices.go package main import ( "context" "fmt" "net/http" ) // BEST PRACTICE 1: Context is the first parameter // Why: Convention makes code predictable and easier to maintain // Good func ProcessData(ctx context.Context, data []byte) error { return nil } // Bad - context should be first // func ProcessData(data []byte, ctx context.Context) error // BEST PRACTICE 2: Don't store context in structs // Why: Context is request-scoped, not instance-scoped // Bad type BadService struct { ctx context.Context // Don't do this! } // Good - pass context to methods type GoodService struct{} func (s *GoodService) DoWork(ctx context.Context) error { return nil } // BEST PRACTICE 3: Always call cancel // Why: Releases resources even if context hasn't timed out func goodCancelUsage() { ctx, cancel := context.WithTimeout(context.Background(), 0) defer cancel() // Always defer cancel! _ = ctx } // BEST PRACTICE 4: Use context.Value sparingly // Why: Type safety is lost, makes dependencies implicit type contextKey string const ( userIDKey contextKey = "userID" requestIDKey contextKey = "requestID" ) // Good - typed keys prevent collisions func SetUserID(ctx context.Context, userID string) context.Context { return context.WithValue(ctx, userIDKey, userID) } func GetUserID(ctx context.Context) (string, bool) { userID, ok := ctx.Value(userIDKey).(string) return userID, ok } // BEST PRACTICE 5: Check ctx.Err() in loops func processItems(ctx context.Context, items []int) error { for _, item := range items { // Check before expensive operation if ctx.Err() != nil { return ctx.Err() } // Process item... _ = item } return nil } // BEST PRACTICE 6: Use ctx.Done() in select statements func longOperation(ctx context.Context) error { // Simulated long operation done := make(chan struct{}) go func() { // Do work... close(done) }() select { case <-done: return nil case <-ctx.Done(): return ctx.Err() } } // BEST PRACTICE 7: Middleware should pass context func LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Add request ID to context ctx := context.WithValue(r.Context(), requestIDKey, "req-123") r = r.WithContext(ctx) next.ServeHTTP(w, r) }) } func main() { fmt.Println("Context Best Practices - See code comments") }
When to Use Context
Use Context For:
| Scenario | Why |
|---|---|
| HTTP handlers | Request-scoped cancellation |
| Database queries | Query timeouts |
| API calls | Request timeouts |
| Long-running operations | Graceful cancellation |
| Parallel operations | Shared cancellation |
Don't Use Context For:
| Scenario | Better Alternative |
|---|---|
| Passing required data | Function parameters |
| Optional dependencies | Options pattern |
| Global configuration | Package-level config |
| Storing large data | Dedicated request struct |
Summary
Context is Go's standard way to:
- Cancel operations (user cancellation, timeouts)
- Set deadlines (max operation duration)
- Pass request-scoped values (trace IDs, user info)
Key Takeaways:
- Context is first parameter: Convention, not just style
- Always cancel: Use
defer cancel()to free resources - Check ctx.Done(): In loops and select statements
- Don't store in structs: Context is request-scoped
- Use Value sparingly: For cross-cutting concerns only
Next Steps
- Practice: Add context to all your HTTP handlers
- Explore: OpenTelemetry for distributed tracing
- Read: Go blog post on context
- Build: Graceful shutdown for your services