Context WithTimeout

Use context.WithTimeout to create a deadline for operations in Go.

When requests hang forever

You click "Submit Order." The spinner turns. Five seconds pass. Ten. Your browser gives up and shows a timeout error. Meanwhile, on the server, a goroutine is still happily fetching data from a sluggish database, burning memory and CPU for no reason. The user has left. The connection is dead. The work is wasted. This is the classic "zombie request" problem. context.WithTimeout is the tool that kills zombie requests before they eat your resources.

How context.WithTimeout works

context.WithTimeout creates a context that cancels itself after a specific duration. You pass a parent context and a time.Duration. It returns a new context and a cancel function. The new context holds a timer. When the timer fires, the context signals cancellation. Any code holding that context sees the signal and stops.

Think of it like a microwave timer. You set the duration. The microwave runs. If the timer hits zero, the microwave stops and beeps. If you press "Stop" early, it stops immediately. The cancel function is the "Stop" button. The timer is the automatic stop.

The context implementation uses a goroutine to manage the timer. That goroutine waits until the timer fires or until you call cancel. When either happens, the goroutine closes a channel and exits. Closing the channel notifies all listeners that the context is done.

Convention: context is plumbing

Go conventions around context are strict. context.Context always goes as the first parameter in a function signature. The variable name is almost always ctx. This pattern lets tools and humans spot context usage instantly. Functions that accept a context must check ctx.Done() or pass the context down to other functions that do. Ignoring the context breaks the cancellation chain.

Context is only for cancellation and deadlines. Do not use context to pass optional parameters, user IDs, or request metadata. That misuse makes code harder to test and obscures the purpose of the context. If you need to pass data, create a struct and pass it explicitly. Context is plumbing. Run it through every long-lived call site.

Minimal example

This example shows the basic pattern. The function simulates work and respects the timeout.

package main

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

// DoWork simulates a slow operation that respects context cancellation.
// It returns nil on success or an error if the context times out.
func DoWork(ctx context.Context) error {
	// Select allows waiting on multiple channels.
	// We wait for either the work to finish or the context to cancel.
	select {
	case <-time.After(3 * time.Second):
		// Work finished successfully within the time limit.
		return nil
	case <-ctx.Done():
		// Context was cancelled or timed out.
		// Return the context error to propagate the reason.
		return ctx.Err()
	}
}

func main() {
	// Create a context that times out after 1 second.
	// Background is the root context. WithTimeout adds the deadline.
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	// Always defer cancel to release resources.
	// Even if the timeout fires, calling cancel is safe and cleans up the timer.
	defer cancel()

	// Pass ctx to the function.
	err := DoWork(ctx)
	if err != nil {
		// Handle the error. ctx.Err() returns context.DeadlineExceeded on timeout.
		fmt.Println("Operation failed:", err)
	} else {
		fmt.Println("Operation succeeded")
	}
}

defer cancel() is not optional. Call it immediately after creating the context.

Walkthrough: the lifecycle

When context.WithTimeout runs, it allocates a timer internally. It returns a context that holds a channel. When the timer fires, it closes that channel. The cancel function stops the timer and closes the channel immediately if you call it early.

In DoWork, the select statement blocks. It watches ctx.Done(). If the timeout hits, ctx.Done() becomes readable. The select picks that case. The function returns ctx.Err(). The error value is context.DeadlineExceeded. This error tells the caller exactly why the operation stopped.

The defer cancel() in main ensures the timer is stopped if the function returns early. If DoWork returned immediately, the timer would still be running. Calling cancel stops the timer and releases the internal goroutine. Without cancel, the goroutine stays alive until the timer fires. If you create thousands of contexts without cancelling them, you leak goroutines. The compiler won't catch this. You'll see memory usage creep up over time.

ctx.Err() returns nil if the context is active. It returns context.Canceled if cancel() was called manually. It returns context.DeadlineExceeded if the timer fired. This distinction helps debugging. You can check the error type to decide how to handle the failure.

Realistic example: HTTP handler

Real code usually involves HTTP handlers or database queries. This example shows an HTTP handler that calls a downstream service with a timeout.

package main

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

// FetchData simulates calling an external API.
// It respects the context deadline to avoid hanging the handler.
func FetchData(ctx context.Context, url string) (string, error) {
	// Create a request with the context.
	// This allows the HTTP client to cancel the request if the context expires.
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return "", fmt.Errorf("creating request: %w", err)
	}

	// Execute the request.
	// http.Client will respect the context cancellation.
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("fetching data: %w", err)
	}
	// Close body to prevent resource leaks.
	defer resp.Body.Close()

	// Simulate processing.
	// In real code, you would read resp.Body here.
	return "data", nil
}

// HandleRequest is an HTTP handler that enforces a timeout on the downstream call.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
	// Derive a new context with a 2-second timeout from the request context.
	// The request context already has a cancellation signal from the client disconnect.
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	// Pass the timeout context to the fetch function.
	data, err := FetchData(ctx, "https://slow-service.example.com/data")
	if err != nil {
		// Check if the error is due to timeout.
		if ctx.Err() == context.DeadlineExceeded {
			http.Error(w, "Service too slow", http.StatusGatewayTimeout)
			return
		}
		http.Error(w, "Fetch failed", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusOK)
	fmt.Fprint(w, data)
}

Derive timeouts from request context. Never use context.Background in a handler.

Pitfalls and errors

Forgetting defer cancel() is the most common mistake. If you forget to call cancel, the timer keeps running. The internal goroutine stays alive until the timer fires. If you create contexts in a loop without cancelling them, you leak goroutines. The leak is silent. The program runs slower and uses more memory until it crashes.

Passing nil as the parent context causes a panic. context.WithTimeout expects a valid context. The runtime panics with WithTimeout: parent is nil. Always pass a real context, even if it's just context.Background.

Using time.Sleep inside a loop ignores context. time.Sleep blocks until the duration passes. It does not check ctx.Done(). If the context times out, the sleep continues. The goroutine hangs. Use a select with time.After instead.

// BAD: Sleep ignores context.
time.Sleep(10 * time.Second)

// GOOD: Sleep respects context.
select {
case <-time.After(10 * time.Second):
	// Sleep finished.
case <-ctx.Done():
	// Context cancelled.
	return ctx.Err()
}

If you try to use context.WithTimeout with a negative duration, the compiler doesn't stop you. The function returns immediately with a cancelled context. You'll get context.Canceled as the error. The code runs, but the logic is wrong. Validate durations before creating contexts.

The worst goroutine bug is the one that never logs.

Chaining timeouts

When you nest timeouts, the inner timeout must be shorter than the outer one. If the outer context cancels, the inner context sees the cancellation immediately. The inner timer is stopped. This prevents resource waste.

If you set an inner timeout longer than the outer one, the outer timeout wins. The inner timer fires later, but the context is already cancelled. The extra timer is wasted. Always subtract a margin when deriving inner timeouts. If the handler has a 5-second timeout, the database call should have a 4-second timeout. This leaves time for error handling and response writing.

// Handler has 5 second timeout.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()

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

// Use dbCtx for the query.

Decision matrix

Use context.WithTimeout when you need an operation to finish within a specific duration. Use context.WithDeadline when you have an absolute timestamp for cancellation, like a scheduled maintenance window. Use context.WithCancel when you need manual control over cancellation, such as stopping a background worker on shutdown. Use context.Background only at the entry points of your program, like main or HTTP handlers. Use context.TODO as a placeholder when you don't have a context yet and need to refactor later.

Pick the right context function. Don't overcomplicate the chain.

Where to go next