Go Context: Cancellation and Deadline Propagation

context.Context is Go's mechanism for propagating cancellation signals, deadlines, and request-scoped values across goroutine boundaries. Correct context usage is essential for building servers that r

Introduction#

context.Context is Go’s mechanism for propagating cancellation signals, deadlines, and request-scoped values across goroutine boundaries. Correct context usage is essential for building servers that respond gracefully to timeouts and shutdowns.

Context Basics#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// The four context constructors
ctx := context.Background()           // root context, never cancelled
ctx := context.TODO()                 // placeholder, same as Background()

ctx, cancel := context.WithCancel(parent)    // cancelled by calling cancel()
defer cancel()  // ALWAYS defer cancel to avoid context leaks

ctx, cancel := context.WithTimeout(parent, 5*time.Second)  // deadline in duration
defer cancel()

ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
defer cancel()

ctx := context.WithValue(parent, key, value)  // attach values

Propagating Context Through Call Chains#

Every function that does I/O or calls other services must accept and pass a context.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// CORRECT: context as first parameter
func GetUser(ctx context.Context, userID int) (*User, error) {
    return db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", userID)
}

// HTTP handler: extract context from request
func handleGetUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()  // deadline from server's read timeout
    userID := extractUserID(r)

    user, err := GetUser(ctx, userID)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(user)
}

Setting Timeouts on Outbound Calls#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func fetchUserProfile(ctx context.Context, userID int) (*Profile, error) {
    // Add a timeout for this specific downstream call
    // even if the parent context has a longer deadline
    callCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(callCtx, "GET",
        fmt.Sprintf("https://profile-service/users/%d", userID), nil)
    if err != nil {
        return nil, err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("profile service: %w", err)
    }
    defer resp.Body.Close()

    var profile Profile
    return &profile, json.NewDecoder(resp.Body).Decode(&profile)
}

Checking Cancellation in Long Operations#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func processItems(ctx context.Context, items []Item) error {
    for i, item := range items {
        // Check for cancellation every iteration
        select {
        case <-ctx.Done():
            return fmt.Errorf("cancelled after %d items: %w", i, ctx.Err())
        default:
        }

        if err := processItem(ctx, item); err != nil {
            return err
        }
    }
    return nil
}

// Alternative: check inline
func heavyComputation(ctx context.Context, data []float64) (float64, error) {
    result := 0.0
    for i, v := range data {
        if i%1000 == 0 {  // check every 1000 iterations
            if err := ctx.Err(); err != nil {
                return 0, err
            }
        }
        result += compute(v)
    }
    return result, nil
}

Context Values: Request-Scoped Data#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Define typed keys to avoid collisions
type contextKey string

const (
    requestIDKey contextKey = "request_id"
    userIDKey    contextKey = "user_id"
)

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
}

// HTTP middleware: attach request ID to context
func requestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        reqID := r.Header.Get("X-Request-ID")
        if reqID == "" {
            reqID = uuid.New().String()
        }
        ctx := WithRequestID(r.Context(), reqID)
        w.Header().Set("X-Request-ID", reqID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Context values should hold request-scoped data (trace IDs, auth tokens) — not optional function parameters. If a function needs a value to work correctly, pass it as a regular parameter.

Context Leak Detection#

1
2
3
4
5
6
7
8
9
10
11
12
13
// WRONG: cancel not called — context leaks until parent is cancelled
func badCode(ctx context.Context) {
    childCtx, _ := context.WithTimeout(ctx, 5*time.Second)
    // childCtx is never cancelled explicitly
    doWork(childCtx)
}

// CORRECT: always defer cancel
func goodCode(ctx context.Context) {
    childCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    doWork(childCtx)
}

Use go vet and golangci-lint to detect missing cancel calls:

1
2
# golangci-lint includes contextcheck linter
golangci-lint run --enable contextcheck ./...

Server Shutdown with Context#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func main() {
    server := &http.Server{Addr: ":8080"}

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)

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

    <-quit
    log.Println("Shutting down...")

    // Give in-flight requests 30 seconds to complete
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("Shutdown error: %v", err)
    }
    log.Println("Server stopped")
}

Conclusion#

Pass context as the first parameter to every function that does I/O. Add per-call timeouts for downstream services rather than relying solely on the parent deadline. Always defer cancel() immediately after creating a cancellable context. Use typed keys for context values. Check ctx.Done() in long-running loops to enable prompt cancellation.

Contents