Introduction

Your Go application cancels a context to signal goroutines to stop, but they continue running. The cancellation signal doesn't propagate through the context chain, causing goroutine leaks and resource exhaustion.

Symptoms

Goroutines don't stop:

```go func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()

go worker(ctx)

time.Sleep(10 * time.Second) // Worker still running after context canceled! }

func worker(ctx context.Context) { for { select { default: doWork() // Never checks ctx.Done() } } } ```

Memory leak from goroutines:

```bash $ curl http://localhost:6060/debug/pprof/goroutine?debug=1

goroutine profile: total 5000 # Too many goroutines!

100 @ 0x1234 0x5678 # 0x1234 main.worker+0x50 /app/main.go:20 # 0x5678 runtime.gopark+0x100

# Goroutine leak detected ```

Context deadline exceeded but operations continue:

```bash $ go test -v

--- FAIL: TestWorker (10.00s) main_test.go:50: context deadline exceeded main_test.go:51: expected worker to stop, but still running FAIL ```

Common Causes

  1. 1.Not checking ctx.Done() - Goroutine never selects on done channel
  2. 2.Wrong context passed - Using parent context instead of derived
  3. 3.Blocking operations - Code blocked before reaching select
  4. 4.Context not derived - Using background context without cancel
  5. 5.Goroutine copied context - Context value lost after goroutine spawn
  6. 6.HTTP client ignoring context - Using http.Get instead of Request with context
  7. 7.Database query not using context - SQL query without ctx parameter
  8. 8.Channel send blocks - Select blocked on channel send, not ctx.Done()

Step-by-Step Fix

Step 1: Identify Where Context is Not Checked

```go // BAD: Never checks context func worker(ctx context.Context) { for { processData() // Runs forever } }

// BAD: Checks too late func worker(ctx context.Context) { for { data := readFromDB() // Blocking, no context if ctx.Err() != nil { // Never reached return } processData(data) } }

// BAD: Blocking send blocks select func worker(ctx context.Context, results chan<- Result) { for { select { case results <- doWork(): // Blocks if channel full! // ctx.Done() never checked } } }

// GOOD: Check context in select func worker(ctx context.Context) { for { select { case <-ctx.Done(): log.Println("Context canceled:", ctx.Err()) return default: if err := processData(ctx); err != nil { return } } } }

// GOOD: Non-blocking check before work func worker(ctx context.Context) { for { if ctx.Err() != nil { return } processData() } } ```

Step 2: Fix Context Chain Propagation

```go package main

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

// BAD: Using wrong context in chain func processData(ctx context.Context, data []byte) error { // Creating new context breaks the chain! ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()

return saveToDatabase(ctx, data) }

// GOOD: Derive from parent context func processData(ctx context.Context, data []byte) error { // Derive from parent to maintain cancellation chain ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel()

return saveToDatabase(ctx, data) }

// Example: Proper context chain func main() { // Root context ctx := context.Background()

// User request timeout ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel()

// Add trace ID ctx = context.WithValue(ctx, "traceID", "abc123")

// Pass to handler if err := handleRequest(ctx); err != nil { log.Fatal(err) } }

func handleRequest(ctx context.Context) error { // Derive with additional timeout ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel()

// Pass to workers return processInParallel(ctx) }

func processInParallel(ctx context.Context) error { results := make(chan error, 3)

// All workers share same context for i := 0; i < 3; i++ { go func(id int) { results <- doWork(ctx, id) }(i) }

// Wait for all or cancellation for i := 0; i < 3; i++ { select { case err := <-results: if err != nil { return err } case <-ctx.Done(): return ctx.Err() } }

return nil } ```

Step 3: Handle Blocking Operations with Context

```go package main

import ( "context" "database/sql" "net/http" "time" )

// BAD: Blocking operation ignores context func fetchData(ctx context.Context, url string) ([]byte, error) { resp, err := http.Get(url) // No context support! if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) }

// GOOD: Use context-aware HTTP client func fetchData(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err }

resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close()

return io.ReadAll(resp.Body) }

// GOOD: Database with context func queryDatabase(ctx context.Context, db *sql.DB) error { // Use context-aware query rows, err := db.QueryContext(ctx, "SELECT * FROM users") if err != nil { return err } defer rows.Close()

for rows.Next() { if ctx.Err() != nil { return ctx.Err() } // Process row } return nil }

// GOOD: Channel operations with context func processChannel(ctx context.Context, input <-chan Data, output chan<- Result) { for { select { case <-ctx.Done(): // Drain input channel to prevent goroutine leak go func() { for range input {} }() return

case data, ok := <-input: if !ok { return }

// Non-blocking send with context check select { case output <- process(data): case <-ctx.Done(): return } } } }

// GOOD: Long-running operation with periodic checks func longProcess(ctx context.Context) error { for i := 0; i < 1000000; i++ { // Check every 100 iterations if i%100 == 0 { select { case <-ctx.Done(): return ctx.Err() default: } }

// Do work doStep(i) } return nil } ```

Step 4: Fix HTTP Server Context Propagation

```go package main

import ( "context" "net/http" "time" )

// BAD: Handler doesn't respect request context func handler(w http.ResponseWriter, r *http.Request) { // Ignoring r.Context()! result := slowOperation() // Not cancelable w.Write(result) }

// GOOD: Use request context func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context()

result, err := slowOperationWithContext(ctx) if err != nil { if ctx.Err() == context.Canceled { // Client disconnected log.Println("Request canceled by client") return } http.Error(w, err.Error(), http.StatusInternalServerError) return }

w.Write(result) }

// GOOD: Graceful shutdown with context func main() { server := &http.Server{ Addr: ":8080", Handler: http.HandlerFunc(handler), }

// Start server in goroutine go func() { if err := server.ListenAndServe(); err != http.ErrServerClosed { log.Fatal(err) } }()

// Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit

// Create shutdown context ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel()

// Shutdown propagates cancellation to all handlers if err := server.Shutdown(ctx); err != nil { log.Fatal("Server shutdown error:", err) }

log.Println("Server stopped") }

// GOOD: Propagate context through middleware func contextMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Add timeout to context ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel()

// Add values to context ctx = context.WithValue(ctx, "requestID", generateID())

// Call next handler with updated context next.ServeHTTP(w, r.WithContext(ctx)) }) } ```

Step 5: Debug Context Propagation Issues

```go package main

import ( "context" "log" "runtime" "runtime/pprof" "time" )

// Debug wrapper for context func debugContext(ctx context.Context, name string) context.Context { ctx, cancel := context.WithCancel(ctx)

go func() { <-ctx.Done() log.Printf("[%s] Context canceled: %v", name, ctx.Err()) }()

// Store cancel for debugging return context.WithValue(ctx, "cancel", cancel) }

// Check goroutine leaks func checkGoroutineLeaks() { before := runtime.NumGoroutine()

// Run your code here runTests()

time.Sleep(100 * time.Millisecond) // Let goroutines finish

after := runtime.NumGoroutine() if after > before { log.Printf("Potential goroutine leak: before=%d, after=%d", before, after)

// Print goroutine stack traces buf := make([]byte, 1<<20) n := runtime.Stack(buf, true) log.Printf("Goroutine dump:\n%s", buf[:n]) } }

// Use pprof to find leaks func startPprof() { go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }() }

// Debug context tree func printContextTree(ctx context.Context, indent int) { prefix := "" for i := 0; i < indent; i++ { prefix += " " }

log.Printf("%sContext type: %T", prefix, ctx) log.Printf("%sContext err: %v", prefix, ctx.Err())

// Check for deadline if deadline, ok := ctx.Deadline(); ok { log.Printf("%sDeadline: %v", prefix, deadline) }

// Check for values // Note: Standard library doesn't expose parent, but can use custom wrapper }

// Example test for context propagation func TestContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background())

done := make(chan struct{}) go func() { defer close(done) worker(ctx) }()

// Give worker time to start time.Sleep(100 * time.Millisecond)

// Cancel context cancel()

// Wait for worker to finish select { case <-done: // Good, worker stopped case <-time.After(time.Second): t.Error("Worker did not stop after context cancellation") } } ```

Step 6: Handle Context in Concurrent Patterns

```go package main

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

// BAD: Goroutine leak when context canceled func processConcurrent(ctx context.Context, items []Item) error { var wg sync.WaitGroup

for _, item := range items { wg.Add(1) go func(i Item) { defer wg.Done() processItem(i) // Runs even after context canceled }(item) }

wg.Wait() return nil }

// GOOD: Early return on context cancellation func processConcurrent(ctx context.Context, items []Item) error { ctx, cancel := context.WithCancel(ctx) defer cancel()

var wg sync.WaitGroup errCh := make(chan error, len(items))

for _, item := range items { wg.Add(1) go func(i Item) { defer wg.Done()

// Check context before work if ctx.Err() != nil { return }

if err := processItemWithContext(ctx, i); err != nil { select { case errCh <- err: cancel() // Cancel all other workers default: } } }(item) }

// Wait in goroutine go func() { wg.Wait() close(errCh) }()

select { case err := <-errCh: return err case <-ctx.Done(): return ctx.Err() } }

// GOOD: Worker pool with context func workerPool(ctx context.Context, jobs <-chan Job, results chan<- Result, workers int) error { var wg sync.WaitGroup wg.Add(workers)

for i := 0; i < workers; i++ { go func() { defer wg.Done() for { select { case <-ctx.Done(): return case job, ok := <-jobs: if !ok { return }

select { case results <- processJob(ctx, job): case <-ctx.Done(): return } } } }() }

// Wait for workers go func() { wg.Wait() close(results) }()

return nil }

// GOOD: Pipeline with context propagation func pipeline(ctx context.Context, input <-chan Data) <-chan Result { output := make(chan Result)

go func() { defer close(output)

for { select { case <-ctx.Done(): return case data, ok := <-input: if !ok { return }

result := transform(ctx, data)

select { case output <- result: case <-ctx.Done(): return } } } }()

return output } ```

Step 7: Use Context with External Dependencies

```go package main

import ( "context" "database/sql" "github.com/redis/go-redis/v9" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "time" )

// GOOD: Redis with context func redisWithCtx(ctx context.Context) error { rdb := redis.NewClient(&redis.Options{ Addr: "localhost:6379", })

// All operations take context val, err := rdb.Get(ctx, "key").Result() if err != nil { return err }

// With timeout ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel()

return rdb.Set(ctx, "key", "value", 0).Err() }

// GOOD: MongoDB with context func mongoWithCtx(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel()

client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017")) if err != nil { return err } defer client.Disconnect(ctx)

collection := client.Database("test").Collection("users")

// Find with context cursor, err := collection.Find(ctx, bson.M{}) if err != nil { return err } defer cursor.Close(ctx)

for cursor.Next(ctx) { // Process document }

return cursor.Err() }

// GOOD: gRPC with context func grpcWithCtx(ctx context.Context, client pb.UserServiceClient) error { // Set timeout on context ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel()

// Call with context resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"}) if err != nil { return err }

log.Printf("User: %v", resp) return nil }

// GOOD: Kafka with context func kafkaConsumer(ctx context.Context, r *kafka.Reader) error { for { select { case <-ctx.Done(): return ctx.Err() default: // Read with context msg, err := r.ReadMessage(ctx) if err != nil { if ctx.Err() != nil { return ctx.Err() } return err }

// Process message processMessage(ctx, msg) } } } ```

Step 8: Implement Context Timeout Patterns

```go package main

import ( "context" "errors" "time" )

// GOOD: Timeout with fallback func fetchWithTimeout(ctx context.Context, url string) ([]byte, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel()

result, err := fetchData(ctx, url) if errors.Is(err, context.DeadlineExceeded) { // Fallback to cache return getFromCache(url) } return result, err }

// GOOD: Timeout different operations differently func complexOperation(ctx context.Context) error { // Auth: 2 seconds authCtx, authCancel := context.WithTimeout(ctx, 2*time.Second) defer authCancel()

if err := authenticate(authCtx); err != nil { return err }

// DB: 5 seconds dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second) defer dbCancel()

data, err := queryDatabase(dbCtx) if err != nil { return err }

// External API: 3 seconds apiCtx, apiCancel := context.WithTimeout(ctx, 3*time.Second) defer apiCancel()

return callExternalAPI(apiCtx, data) }

// GOOD: Propagate deadline if shorter func withDeadlineFromParent(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { if deadline, ok := ctx.Deadline(); ok { // If parent has sooner deadline, use it if time.Until(deadline) < timeout { return context.WithDeadline(ctx, deadline) } } return context.WithTimeout(ctx, timeout) }

// GOOD: Circuit breaker with context func withCircuitBreaker(ctx context.Context, name string, fn func(context.Context) error) error { cb := getCircuitBreaker(name)

if !cb.Ready() { return errors.New("circuit breaker open") }

start := time.Now() err := fn(ctx) duration := time.Since(start)

if err != nil { cb.RecordFailure(duration) } else { cb.RecordSuccess(duration) }

return err } ```

Step 9: Test Context Propagation

```go package main

import ( "context" "testing" "time" )

// Test context cancellation propagates func TestContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background())

// Start worker done := make(chan struct{}) go func() { defer close(done) worker(ctx) }()

// Wait for worker to start time.Sleep(100 * time.Millisecond)

// Cancel context cancel()

// Verify worker stops select { case <-done: // Success case <-time.After(time.Second): t.Error("Worker did not stop after context cancellation") } }

// Test deadline propagation func TestDeadlinePropagation(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel()

start := time.Now() err := slowOperation(ctx) duration := time.Since(start)

if err != context.DeadlineExceeded { t.Errorf("expected DeadlineExceeded, got %v", err) }

if duration > 3*time.Second { t.Errorf("operation took too long: %v", duration) } }

// Test context value propagation func TestContextValuePropagation(t *testing.T) { ctx := context.Background() ctx = context.WithValue(ctx, "traceID", "abc123") ctx = context.WithValue(ctx, "userID", "user456")

// Derive new context ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel()

// Values should propagate if traceID, ok := ctx.Value("traceID").(string); !ok || traceID != "abc123" { t.Error("traceID not propagated") }

if userID, ok := ctx.Value("userID").(string); !ok || userID != "user456" { t.Error("userID not propagated") } }

// Benchmark context operations func BenchmarkContextCancel(b *testing.B) { for i := 0; i < b.N; i++ { ctx, cancel := context.WithCancel(context.Background()) cancel() _ = ctx } }

func BenchmarkContextSelect(b *testing.B) { ctx, cancel := context.WithCancel(context.Background()) defer cancel()

b.ResetTimer() for i := 0; i < b.N; i++ { select { case <-ctx.Done(): default: } } } ```

Step 10: Implement Production Context Management

```go package main

import ( "context" "log" "os" "os/signal" "runtime/debug" "syscall" "time" )

// Context manager for application type ContextManager struct { rootCtx context.Context rootCancel context.CancelFunc }

func NewContextManager() *ContextManager { ctx, cancel := context.WithCancel(context.Background())

// Handle signals go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) <-sigCh log.Println("Received shutdown signal") cancel() }()

return &ContextManager{ rootCtx: ctx, rootCancel: cancel, } }

func (cm *ContextManager) Context() context.Context { return cm.rootCtx }

func (cm *ContextManager) Shutdown() { cm.rootCancel() }

func (cm *ContextManager) WithTimeout(timeout time.Duration) (context.Context, context.CancelFunc) { return context.WithTimeout(cm.rootCtx, timeout) }

func (cm *ContextManager) WithValue(key, value interface{}) context.Context { return context.WithValue(cm.rootCtx, key, value) }

// Request context with all necessary values type RequestContext struct { TraceID string UserID string SpanID string Deadline time.Time }

func NewRequestContext(parent context.Context, req *http.Request) (context.Context, context.CancelFunc) { // Add timeout ctx, cancel := context.WithTimeout(parent, 30*time.Second)

// Add trace info traceID := req.Header.Get("X-Trace-ID") if traceID == "" { traceID = generateTraceID() } ctx = context.WithValue(ctx, "traceID", traceID)

// Add user info userID := req.Header.Get("X-User-ID") ctx = context.WithValue(ctx, "userID", userID)

// Add span for tracing spanID := generateSpanID() ctx = context.WithValue(ctx, "spanID", spanID)

return ctx, cancel }

// Helper to extract context values func GetTraceID(ctx context.Context) string { if v := ctx.Value("traceID"); v != nil { return v.(string) } return "" }

func GetUserID(ctx context.Context) string { if v := ctx.Value("userID"); v != nil { return v.(string) } return "" }

// Best practices summary: // 1. Always pass context as first parameter // 2. Don't store context in structs // 3. Derive from parent context, never create new chain // 4. Always call cancel function // 5. Check ctx.Done() in loops // 6. Use context-aware libraries // 7. Set appropriate timeouts // 8. Propagate context through all layers // 9. Test cancellation behavior // 10. Monitor goroutine leaks

// Example complete application: func main() { ctxMgr := NewContextManager() defer ctxMgr.Shutdown()

// Run application with context if err := run(ctxMgr.Context()); err != nil { log.Fatal(err) } }

func run(ctx context.Context) error { // Create HTTP server with context-aware handlers server := &http.Server{ Addr: ":8080", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Derive request context from app context ctx, cancel := NewRequestContext(ctx, r) defer cancel()

// Handle with context handleRequest(ctx, w, r) }), }

// Run server in goroutine errCh := make(chan error, 1) go func() { if err := server.ListenAndServe(); err != http.ErrServerClosed { errCh <- err } }()

// Wait for shutdown or error select { case <-ctx.Done(): // Graceful shutdown shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return server.Shutdown(shutdownCtx) case err := <-errCh: return err } } ```

Go Context Propagation Checklist

CheckPatternExpected
Context checkedselect <-ctx.Done()In every loop
Context passedfunc(ctx, ...)First parameter
Context derivedWithTimeout/CancelFrom parent
Cancel calleddefer cancel()Always
HTTP uses contextRequestWithContextClient.Do
DB uses contextQueryContextAll queries
Goroutines checkctx.Done() caseIn select

Verification

```bash # After fixing context propagation:

# 1. Run tests go test -v -race ./... # All tests pass, no race conditions

# 2. Check for goroutine leaks go test -run TestGoroutineLeak # No leaks detected

# 3. Run with pprof go run main.go & curl http://localhost:6060/debug/pprof/goroutine?debug=1 # Reasonable number of goroutines

# 4. Load test ab -n 1000 -c 100 http://localhost:8080/ # Requests properly canceled

# 5. Check metrics # Request latency should match context timeout # Goroutine count should stay stable

# Compare before/after: # Before: Goroutine leak, requests hang, no cancellation # After: Clean shutdown, context propagates, goroutines stop ```

  • [Fix Go Goroutine Leak](/articles/fix-go-goroutine-leak)
  • [Fix Go Channel Deadlock](/articles/fix-go-channel-deadlock)
  • [Fix Go HTTP Client Timeout](/articles/fix-go-http-client-timeout)
  • [Technical troubleshooting: Fix GORM Connection Timeout in GO](fix-gorm-connection-timeout-in-go)
  • [Technical troubleshooting: Fix GORM Resource Not Found in GO](fix-gorm-resource-not-found-in-go)
  • [Technical troubleshooting: Fix Build Cache Corrupted After Os Upgrade Issue i](build-cache-corrupted-after-os-upgrade)
  • [Technical troubleshooting: Fix Go Build Constraint Wrong Goos Fix Issue in Go](go-build-constraint-wrong-goos-fix)
  • [Technical troubleshooting: Fix Echo Permission Denied in GO](fix-echo-permission-denied-in-go)

<script type="application/ld+json"> { "@context": "https://schema.org", "@type": "TechArticle", "headline": "Fix Go Context Cancellation Not Propagating", "description": "Troubleshoot Go context cancellation issues. Fix propagation failures, goroutine leaks, and proper context chain management.", "url": "https://www.fixwikihub.com/fix-go-context-cancellation-not-propagating", "publisher": { "@type": "Organization", "name": "FixWikiHub", "url": "https://www.fixwikihub.com" }, "author": { "@type": "Person", "name": "FixWikiHub Editorial Team" }, "datePublished": "2025-12-11T00:40:35.574Z", "dateModified": "2025-12-11T00:40:35.574Z" } </script>