How to Use context.WithTimeout in Go

Create a context with a deadline using context.WithTimeout to automatically cancel operations after a set duration.

The hanging request problem

A user hits your API endpoint. Your server accepts the request and spawns a goroutine to handle it. That goroutine calls a database, which calls a cache, which calls an external service. The external service is slow. It takes thirty seconds to respond. Your goroutine sits there, blocked, waiting for the answer.

One request is harmless. A hundred requests fill up your goroutine stack. A thousand requests exhaust your memory. The server starts swapping to disk. Response times for healthy requests spike. The user sees a 504 Gateway Timeout. The server crashes.

The root cause is a missing deadline. Your code waited forever because it had no way to say "stop waiting." Go solves this with context.WithTimeout. It creates a context that carries a deadline. When the deadline passes, the context cancels automatically. Every function that respects the context wakes up, cleans up, and returns an error. The goroutine finishes. The memory is freed. The server stays alive.

Context as a cancellation signal

A context.Context is a value that travels through your call stack. It carries deadlines, cancellation signals, and request-scoped values. The most important job of a context is cancellation.

Think of a context like a waiter in a busy restaurant. The waiter takes your order and sends it to the kitchen. If the kitchen takes too long, the waiter doesn't stand there staring at the pass window. The waiter checks the timer. When the timer hits five minutes, the waiter cancels the order. The kitchen stops cooking. The waiter tells you the order is cancelled. You get your money back. The table is free for the next customer.

context.WithTimeout is that timer. You pass it a parent context and a duration. It returns a new context and a cancel function. The new context knows when its time is up. When the time is up, it fires the cancel function. The cancel function closes the Done channel inside the context. Any code listening to Done sees the close and stops what it is doing.

Context is plumbing. Run it through every long-lived call site.

Minimal example

Here is the simplest way to use context.WithTimeout. You create the context, defer the cancel function, pass the context to a function, and check the error.

package main

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

// simulateWork blocks for the given duration.
// It returns an error if the context is cancelled before the work finishes.
func simulateWork(ctx context.Context, duration time.Duration) error {
	// Create a channel to signal when the work is done.
	done := make(chan struct{})

	// Start a goroutine to do the work.
	go func() {
		// Sleep to simulate slow work.
		time.Sleep(duration)
		// Close the channel to signal completion.
		close(done)
	}()

	// Wait for either the work to finish or the context to cancel.
	select {
	case <-done:
		// Work finished in time.
		return nil
	case <-ctx.Done():
		// Context was cancelled or timed out.
		return ctx.Err()
	}
}

func main() {
	// Create a context that times out after 2 seconds.
	// The parent is context.Background(), which is the root context.
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	// Defer cancel immediately to release resources.
	// This ensures the timer stops even if the function returns early.
	defer cancel()

	// Call the function with the context.
	// We ask for 5 seconds of work, but the context only allows 2.
	err := simulateWork(ctx, 5*time.Second)
	if err != nil {
		// Handle the error.
		fmt.Printf("Work failed: %v\n", err)
	} else {
		fmt.Println("Work succeeded")
	}
}

The output shows the timeout working. The work asked for five seconds, but the context killed it after two.

# output:
Work failed: context deadline exceeded

How the timer fires under the hood

Understanding what happens inside context.WithTimeout prevents subtle bugs. When you call WithTimeout, Go allocates a time.Timer. This timer is scheduled to fire after the duration you specified.

The timer runs in the background. It does not block your goroutine. When the timer fires, it calls the cancel function that WithTimeout returned to you. The cancel function closes the Done channel.

Closing a channel is a broadcast. Every goroutine that is waiting on <-ctx.Done() wakes up at the same time. They all see the close. They all return ctx.Err(). The error value is context.DeadlineExceeded.

The defer cancel() call is essential. If you forget to call cancel, the timer keeps running until it fires. If the function returns early because the work finished fast, the timer still sits in memory waiting to fire. This is a resource leak. The timer holds a reference to the context, and the context holds references to its children. The garbage collector cannot clean them up until the timer fires. Defer the cancel function immediately after creating the context.

Realistic example: HTTP handler with timeout

In a web server, you usually derive a timeout from the incoming request. The http.Request has a Context() method that returns the request context. This context is cancelled when the client disconnects. You should use this as the parent for your timeout.

Here is a handler that calls a slow service. It sets a timeout of three seconds. If the service is slow, it returns a 504 error.

package main

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

// slowService simulates an external API call.
// It respects context cancellation.
func slowService(ctx context.Context) (string, error) {
	// Simulate network latency.
	select {
	case <-time.After(10 * time.Second):
		// This branch runs if the work finishes.
		return "result", nil
	case <-ctx.Done():
		// This branch runs if the context is cancelled.
		return "", ctx.Err()
	}
}

// handleRequest is an HTTP handler.
// It sets a timeout and calls the slow service.
func handleRequest(w http.ResponseWriter, r *http.Request) {
	// Get the request context.
	// This context is cancelled if the client disconnects.
	parentCtx := r.Context()

	// Create a timeout context derived from the request context.
	// The timeout is 3 seconds.
	ctx, cancel := context.WithTimeout(parentCtx, 3*time.Second)
	// Defer cancel to clean up the timer.
	defer cancel()

	// Call the service with the timeout context.
	result, err := slowService(ctx)
	if err != nil {
		// Check if the error is a deadline exceeded error.
		// Use errors.Is because the error might be wrapped.
		if errors.Is(err, context.DeadlineExceeded) {
			http.Error(w, "Service timeout", http.StatusGatewayTimeout)
			return
		}
		// Handle other errors.
		http.Error(w, "Service error", http.StatusInternalServerError)
		return
	}

	// Return the result.
	fmt.Fprintln(w, result)
}

func main() {
	http.HandleFunc("/api/data", handleRequest)
	// Start server.
	fmt.Println("Server starting on :8080")
	http.ListenAndServe(":8080", nil)
}

This example shows two key patterns. First, the timeout context is derived from the request context. If the client disconnects, the request context cancels, which cancels the timeout context, which cancels the slowService call. The cancellation propagates down the tree. Second, the error check uses errors.Is. The slowService function returns ctx.Err(), which is context.DeadlineExceeded. If you wrap that error with fmt.Errorf, errors.Is still finds it. Direct equality checks fail on wrapped errors.

Accept interfaces, return structs. In this case, accept the context interface, return the concrete error.

Pitfalls and compiler errors

Developers run into three common problems with context.WithTimeout.

Forgetting to defer cancel

If you create a context with a timeout and do not call the cancel function, the timer leaks. The timer stays active until it fires. If the function returns early, the timer runs in the background, holding memory. The compiler does not catch this. You must discipline yourself to defer cancel() immediately.

A common mistake is putting the cancel call at the end of the function instead of deferring it.

ctx, cancel := context.WithTimeout(ctx, time.Second)
// Do work...
cancel() // Wrong: if work panics or returns early, cancel is skipped.

Always use defer.

Checking errors with equality

The context package returns context.DeadlineExceeded and context.Canceled. These are sentinel errors. If you wrap them with fmt.Errorf("timeout: %w", err), the wrapped error is not equal to the sentinel. You must use errors.Is.

// Wrong: fails if error is wrapped.
if err == context.DeadlineExceeded { ... }

// Right: works with wrapped errors.
if errors.Is(err, context.DeadlineExceeded) { ... }

If you forget to import the errors package, the compiler rejects the program with undefined: errors. If you forget to import context, you get undefined: context.

Setting the wrong timeout

Timeouts are a balance. Too short, and you kill healthy requests. Too long, and you leak resources during failures. Start with a generous timeout based on your service's SLA. Monitor the timeout errors. Adjust based on data. Do not guess.

Passing context to non-context-aware functions

If you call a function that does not accept a context, you cannot cancel it. You must wrap the call in a goroutine and select on the context.

// badFunc does not accept context.
func badFunc() string { ... }

// Good pattern:
resultCh := make(chan string, 1)
go func() {
	resultCh <- badFunc()
}()

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return result
}

The worst goroutine bug is the one that never logs. Always log when a context is cancelled, so you can debug timeouts later.

When to use WithTimeout versus alternatives

Go provides several ways to manage context lifecycles. Pick the right tool for the job.

Use context.WithTimeout when you need a deadline relative to the current time. This is the standard choice for HTTP requests, database queries, and RPC calls. You know the operation should take "about 5 seconds," so you set a 5-second timeout.

Use context.WithDeadline when you need an absolute deadline. This is useful when you have a fixed end time, like a batch job that must finish by midnight, or when you are chaining multiple operations and want them all to share the same final deadline.

Use context.WithCancel when you need manual control. This is useful for long-running background tasks that should stop when a user clicks "cancel" in a UI, or when a parent goroutine decides to abort a child operation based on logic.

Use context.Background() when you are at the root of your call tree. This is the entry point for incoming requests, background workers, and tests. Never pass nil as a context.

Use plain sequential code when you don't need concurrency. If a function is fast and synchronous, passing a context adds noise. Context is for operations that can block or run in the background.

Where to go next