Fix

"context deadline exceeded" in Go

Fix context deadline exceeded by increasing the timeout duration or optimizing the slow operation to complete faster.

When the timer rings before the work finishes

You deploy a Go service. Load testing passes. The first week in production is quiet. Then traffic spikes, a downstream API slows down, and your logs suddenly fill with context deadline exceeded. The request does not crash. It just stops. The client receives a timeout error, and you are left tracing a three-second operation that somehow took ten.

The error is not a bug in Go. It is a boundary you set. A context deadline is a hard stop. When the clock passes the limit, Go tells your code to stop waiting. If your code ignores that signal, it hangs. If your code respects it, it returns context.DeadlineExceeded and cleans up. The difference between a smooth timeout and a leaked goroutine comes down to how you wire the context through your call chain.

The timer does not negotiate. It just fires.

How context deadlines actually work

A context.Context is a cancellation signal, not a timeout mechanism itself. When you call context.WithTimeout, Go creates a child context that carries a timer and a read-only channel. That channel is exposed through the Done() method. The channel stays open while the deadline has not passed. The moment the timer expires, Go closes the channel.

Closing a channel in Go broadcasts to every goroutine waiting on it. Any select statement listening to ctx.Done() immediately unblocks. Your code then checks ctx.Err() to see why it stopped. If the timer fired, ctx.Err() returns context.DeadlineExceeded. If you called cancel() manually, it returns context.Canceled.

Think of it like a shared kitchen timer. Multiple cooks can listen to it. When it rings, everyone stops what they are doing and checks the pot. The timer does not force the pot to drain. It just tells the cooks to stop waiting. Your code must decide what to do next.

Context cancellation is cooperative. Your code must listen.

Minimal example

package main

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

// FetchData simulates a slow operation that respects context cancellation.
func FetchData(ctx context.Context) ([]byte, error) {
	// Simulate work that takes 5 seconds to complete
	select {
	case <-time.After(5 * time.Second):
		// Return success if the work finishes before the deadline
		return []byte("payload"), nil
	case <-ctx.Done():
		// Stop waiting immediately when the context signals cancellation
		return nil, ctx.Err()
	}
}

func main() {
	// Create a context with a 2-second deadline
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	// Clean up the timer when main returns to prevent resource leaks
	defer cancel()

	// Pass the context to the blocking operation
	_, err := FetchData(ctx)
	if err != nil {
		// Print the exact error Go returns when the deadline passes
		fmt.Println("Operation failed:", err)
	}
}

Run this program and it prints Operation failed: context deadline exceeded. The time.After channel would fire in five seconds, but the ctx.Done() channel closes after two. The select picks the first ready channel, returns the context error, and exits cleanly. No goroutine sits around waiting for a dead deadline.

Context cancellation is cooperative. Your code must listen.

What happens under the hood

When context.WithTimeout runs, Go allocates a small struct on the heap. That struct holds a timer, a done channel, and a reference to the parent context. The timer starts immediately. When the duration elapses, a background runtime goroutine closes the done channel and calls the parent's cleanup function.

Your select statement registers interest in that channel. The Go scheduler blocks the goroutine until one of the channels is ready. Channel closure is a broadcast. Every goroutine waiting on ctx.Done() wakes up at the same moment. That is why context cancellation scales well. You can fan out ten goroutines from one context, and they all stop together when the deadline passes.

The compiler enforces a few rules to keep this safe. If you try to pass a string where a context is expected, you get cannot use x (variable of type string) as context.Context value in argument. If you forget to import the context package, the compiler rejects the file with undefined: context. These errors catch wiring mistakes before runtime.

At runtime, the error you see is just a string comparison under the hood. context.DeadlineExceeded is a sentinel error. You can compare it directly, but modern Go code prefers errors.Is(err, context.DeadlineExceeded) because it works through wrapped errors.

Propagate the context. Every blocking call needs it.

Realistic HTTP handler

Production code rarely calls time.Sleep or time.After. It calls databases, HTTP clients, or message queues. Those libraries accept a context so they can cancel network dials, abort in-flight queries, or drop pending reads.

package main

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

// QueryDatabase simulates a database call that respects context cancellation.
func QueryDatabase(ctx context.Context, query string) (string, error) {
	// In real code, pass ctx to db.QueryContext(ctx, query)
	// The driver will cancel the query if ctx expires
	select {
	case <-time.After(3 * time.Second):
		return "user_data", nil
	case <-ctx.Done():
		// Return the context error so the caller knows why it stopped
		return "", ctx.Err()
	}
}

// HandleRequest processes an HTTP request with a strict application timeout.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
	// Derive a new context from the request with a 1-second deadline
	ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
	// Always defer cancel to release the timer when the handler returns
	defer cancel()

	// Pass the derived context to the database layer
	result, err := QueryDatabase(ctx, "SELECT * FROM users")
	if err != nil {
		// Check for timeout specifically to return the correct HTTP status
		if errors.Is(err, context.DeadlineExceeded) {
			http.Error(w, "request timed out", http.StatusGatewayTimeout)
			return
		}
		// Handle other database errors separately
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	// Write success response
	w.WriteHeader(http.StatusOK)
	fmt.Fprint(w, result)
}

The key detail here is r.Context(). The net/http server creates a context for every incoming request. It attaches a client-side timeout and a cancellation channel that fires when the client disconnects. Deriving from r.Context() chains your application deadline to the client lifecycle. If the user closes their browser, your database query stops immediately.

Convention matters here. The context.Context parameter always goes first, and the community names it ctx. Functions that accept a context should respect cancellation and deadlines. Never store a context inside a struct. Structs outlive the request lifecycle, and storing a context there breaks cancellation propagation and causes memory leaks.

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

Pitfalls and error handling

The context deadline exceeded error usually points to one of three problems. The timeout is too short for the workload. The call chain drops the context somewhere. Or a blocking operation ignores cancellation entirely.

Dropping the context is the most common wiring mistake. You create a timeout in the handler, pass it to a service layer, and the service layer calls a repository with context.Background() instead of the derived context. The repository runs forever. The handler times out and returns a 504, but the database query keeps chewing CPU and connections. The compiler cannot catch this. You have to audit the call chain.

Blocking operations that ignore context are equally dangerous. If you call a third-party library that does not accept a context, you must wrap it in a goroutine with a select and a timer. Otherwise, the library blocks past your deadline, and your goroutine leaks. The worst goroutine bug is the one that never logs.

Error checking also trips people up. Comparing err == context.DeadlineExceeded works for direct returns. It fails when errors are wrapped with fmt.Errorf("query failed: %w", err). Use errors.Is(err, context.DeadlineExceeded) to match through the wrapper chain. The standard library provides errors.Is exactly for this reason.

Another runtime surprise is calling cancel() twice. It is safe. The cancel function is idempotent. Go guards against double-closing the done channel. You can call it in multiple branches or defer it multiple times without panicking.

Do not swallow context errors. Surface them or handle them explicitly.

Decision matrix

Use context.WithTimeout when you need a hard limit on how long an operation can run from the moment it starts. Use context.WithDeadline when you need to align multiple operations to a specific wall-clock time, like a batch job that must finish by midnight. Use context.WithCancel when you need manual cancellation from another goroutine, a signal handler, or a user action. Use r.Context() in HTTP handlers to inherit client-side timeouts and automatic cancellation on disconnect. Use errors.Is(err, context.DeadlineExceeded) when checking for timeouts in wrapped error chains. Use a longer timeout or retry logic when the operation is legitimately slow but not broken. Use a circuit breaker or fallback cache when downstream services consistently exceed your deadline.

Pick the right context factory. Derive, don't duplicate.

Where to go next