Skip to content

Fix: Go context deadline exceeded / context canceled

FixDevs · (Updated: )

Part of:  Go, Rust & Systems Errors

Quick Answer

How to fix Go context.DeadlineExceeded and context.Canceled errors — setting timeouts correctly, propagating context through call chains, handling cancellation, and debugging which operation timed out.

The Error

A Go program returns a context error:

context deadline exceeded
context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Or in an HTTP handler:

err := db.QueryContext(ctx, query)
// err: context deadline exceeded

Or cancellation propagation:

err := http.Get(url)
// err: Get "https://api.example.com": context canceled

Or the error wraps deeper:

rpc error: code = DeadlineExceeded desc = context deadline exceeded
dial tcp 10.0.0.1:5432: i/o timeout

Why This Happens

Go’s context package propagates deadlines and cancellation signals through call chains. A context.DeadlineExceeded error means an operation didn’t complete within the allotted time; context.Canceled means something upstream explicitly cancelled the context.

This model is unique to Go. Most other languages bolt timeout and cancellation onto their async primitives as an afterthought. Go designed context propagation into the standard library from day one (Go 1.7), and the convention that every I/O function accepts context.Context as its first parameter means that cancellation is pervasive. The trade-off is that context mismanagement — setting timeouts too short, not propagating, or cancelling prematurely — becomes a frequent source of production errors.

The distinction between DeadlineExceeded and Canceled matters for debugging. DeadlineExceeded means the clock ran out. Canceled means something called cancel() explicitly, which usually means the client disconnected, a parent handler returned, or a competing goroutine finished first in a select statement.

Common triggers:

  • Timeout too short — the operation legitimately takes longer than the deadline allows. Common for cold database connections, slow network calls, or large data processing.
  • Context not propagated — a timeout is set on the incoming request context, but the downstream call (database, HTTP, gRPC) uses context.Background() instead of the request context. The timeout isn’t inherited.
  • Parent context cancelled — the HTTP handler’s context is cancelled when the client disconnects. Any ongoing database or HTTP calls using that context are cancelled too.
  • Deadline set in wrong place — deadline set on a context that wraps multiple operations, and one slow operation uses all the time, leaving none for subsequent operations.
  • No timeout at allhttp.Client with no timeout, database query with no context — one hung operation blocks forever.

Fix 1: Set Timeouts at the Right Level

Every external call — HTTP, database, gRPC — needs a timeout. Set them explicitly rather than inheriting an arbitrary parent deadline:

HTTP client timeout:

// WRONG — no timeout, hangs forever on slow servers
client := &http.Client{}
resp, err := client.Get("https://api.example.com/data")

// CORRECT — set a timeout on the client
client := &http.Client{
    Timeout: 10 * time.Second,
}
resp, err := client.Get("https://api.example.com/data")

Database query with context timeout:

// WRONG — no timeout, query runs until DB decides to stop it
rows, err := db.Query("SELECT * FROM large_table")

// CORRECT — add a deadline to the query
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT * FROM large_table")
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        return fmt.Errorf("query timed out after 5s")
    }
    return fmt.Errorf("query failed: %w", err)
}

Always call the cancel function — even if the deadline fires, the cancel function must be called to release context resources:

ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()  // ALWAYS defer cancel — prevents context leak

Common Mistake: Forgetting defer cancel(). Leaked contexts hold goroutines and resources alive until the deadline fires, which can cause gradual memory growth and goroutine leaks in long-running servers.

Fix 2: Propagate Context Through the Call Chain

The request context must flow from the HTTP handler down through every service call. Using context.Background() in a downstream function disconnects it from the parent’s deadline:

// BROKEN — context not propagated
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.userService.GetByID(42)  // userService ignores the request context
    // ...
}

func (s *UserService) GetByID(id int) (*User, error) {
    // Uses context.Background() — not connected to the request context
    ctx := context.Background()
    return s.db.QueryContext(ctx, "SELECT * FROM users WHERE id = $1", id)
}

// CORRECT — pass context through the entire chain
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.userService.GetByID(r.Context(), 42)
    // ...
}

func (s *UserService) GetByID(ctx context.Context, id int) (*User, error) {
    return s.db.QueryContext(ctx, "SELECT * FROM users WHERE id = $1", id)
}

Convention: Every function that performs I/O should accept context.Context as its first parameter:

// Standard Go convention
func FetchData(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, 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)
}

Fix 3: Handle Context Cancellation Gracefully

When a context is cancelled (client disconnect, timeout), in-flight operations return errors. Handle them without panicking:

func processItems(ctx context.Context, items []Item) error {
    for _, item := range items {
        // Check if context is done before each iteration
        select {
        case <-ctx.Done():
            return fmt.Errorf("processing cancelled: %w", ctx.Err())
        default:
            // Continue processing
        }

        if err := processOne(ctx, item); err != nil {
            if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
                // Context was cancelled — stop processing, don't log as error
                return err
            }
            return fmt.Errorf("processing item %d: %w", item.ID, err)
        }
    }
    return nil
}

In HTTP handlers, detect client disconnect:

func (h *Handler) LongOperation(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    result, err := h.service.DoLongWork(ctx)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            // Client disconnected — don't write a response
            log.Printf("client disconnected: %v", r.URL.Path)
            return
        }
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(result)
}

Fix 4: Set Per-Operation Timeouts (Not Just Request-Level)

A single request-level timeout may not give enough flexibility when different operations have different time requirements. Set per-operation timeouts:

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    requestCtx := r.Context()

    // Database lookup — should be fast
    dbCtx, dbCancel := context.WithTimeout(requestCtx, 2*time.Second)
    defer dbCancel()
    user, err := h.db.GetUser(dbCtx, userID)
    if err != nil {
        http.Error(w, "Database error", 500)
        return
    }

    // External payment API — can take longer
    payCtx, payCancel := context.WithTimeout(requestCtx, 15*time.Second)
    defer payCancel()
    payment, err := h.paymentClient.Charge(payCtx, user, amount)
    if err != nil {
        http.Error(w, "Payment failed", 500)
        return
    }

    // Notification — best effort, don't block the response
    go func() {
        notifCtx, notifCancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer notifCancel()
        h.notifier.Send(notifCtx, user.Email, "Order confirmed")
    }()

    json.NewEncoder(w).Encode(payment)
}

Use context.WithDeadline for absolute times instead of durations:

// Deadline at a specific time (useful for scheduled jobs)
deadline := time.Now().Add(30 * time.Minute)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

Fix 5: Debug Which Operation Timed Out

When context deadline exceeded appears deep in a call stack, wrap errors with context to identify the source:

func fetchUserOrders(ctx context.Context, userID int) ([]*Order, error) {
    orders, err := db.QueryContext(ctx,
        "SELECT * FROM orders WHERE user_id = $1", userID)
    if err != nil {
        // Add context to the error — which operation failed?
        return nil, fmt.Errorf("fetchUserOrders(userID=%d): %w", userID, err)
    }
    return orders, nil
}

The wrapped error includes the function name and parameters:

fetchUserOrders(userID=42): context deadline exceeded

Log the deadline information:

if deadline, ok := ctx.Deadline(); ok {
    remaining := time.Until(deadline)
    log.Printf("context deadline: %v (%.1fs remaining)", deadline, remaining.Seconds())
} else {
    log.Println("context has no deadline")
}

Use context.WithValue to trace requests:

type requestIDKey struct{}

// Add request ID to context
ctx = context.WithValue(r.Context(), requestIDKey{}, requestID)

// Retrieve in downstream functions
requestID, _ := ctx.Value(requestIDKey{}).(string)
log.Printf("[%s] database query timed out", requestID)

Fix 6: Increase Timeout for Legitimately Slow Operations

If the operation is genuinely slow and the timeout is too aggressive, measure actual latency first:

start := time.Now()
result, err := expensiveOperation(ctx)
elapsed := time.Since(start)
log.Printf("operation took %v", elapsed)

Then set the timeout to the 99th percentile latency plus buffer:

// If p99 latency is 8 seconds, set timeout to 15 seconds
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
defer cancel()

For batch operations, calculate timeout based on item count:

// Allow 100ms per item, plus 2 seconds overhead
timeout := time.Duration(len(items))*100*time.Millisecond + 2*time.Second
ctx, cancel := context.WithTimeout(parentCtx, timeout)
defer cancel()

Fix 7: Context in goroutines

When spawning goroutines, be careful with context lifetime:

// WRONG — goroutine may outlive the request context
func (h *Handler) Process(w http.ResponseWriter, r *http.Request) {
    go func() {
        // r.Context() is cancelled when the handler returns
        // This goroutine's database call will be cancelled immediately
        result, err := h.db.LongQuery(r.Context())
    }()
}

// CORRECT — use a separate context for background work
func (h *Handler) Process(w http.ResponseWriter, r *http.Request) {
    // Capture values from the request context before it's cancelled
    userID := r.Context().Value(userIDKey{}).(int)

    go func() {
        // Use a fresh context with its own timeout for background work
        bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        result, err := h.db.LongQuery(bgCtx, userID)
    }()
}

Timeout and Cancellation Across Languages

Go’s context.Context is not the only approach to propagating timeouts and cancellation. Comparing how other languages handle the same problem clarifies when Go’s model helps and when it creates friction.

Java: CompletableFuture with Timeout

Java does not have a built-in context propagation mechanism equivalent to Go’s. Timeouts are set per-operation using CompletableFuture.orTimeout() (Java 9+) or Future.get(timeout, unit). Cancellation is done via Future.cancel(true), which sets an interrupt flag on the thread — but the thread must check Thread.interrupted() to actually stop. There is no automatic propagation down a call chain. Libraries like gRPC-Java and Spring implement their own context propagation (e.g., io.grpc.Context), which is conceptually similar to Go’s context but requires explicit threading.

CompletableFuture<Result> future = CompletableFuture.supplyAsync(() -> {
    return expensiveOperation();
}).orTimeout(10, TimeUnit.SECONDS);
// Throws TimeoutException if not complete in 10 seconds

C#: CancellationToken

C#‘s CancellationToken is the closest equivalent to Go’s context. It is passed through method parameters, supports linked tokens (equivalent to child contexts), and is integrated into the entire .NET async ecosystem. The key difference is that CancellationToken only handles cancellation — it does not carry deadlines. You combine it with CancellationTokenSource.CancelAfter(TimeSpan) to create timeout behavior.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var result = await httpClient.GetAsync(url, cts.Token);
// Throws TaskCanceledException on timeout

Rust: tokio::time::timeout

Rust’s Tokio runtime provides tokio::time::timeout() which wraps any future with a deadline. There is no context object that propagates through function arguments. Instead, each operation manages its own timeout. For cancellation, Tokio uses tokio::select! to race futures against a cancellation signal (typically a tokio::sync::watch or CancellationToken from tokio-util).

use tokio::time::{timeout, Duration};

let result = timeout(Duration::from_secs(10), async_operation()).await;
match result {
    Ok(value) => { /* success */ }
    Err(_) => { /* timed out */ }
}

Node.js: AbortController

Node.js adopted the browser’s AbortController API for cancellation. It is supported by fetch, fs.readFile, stream.pipeline, and other core APIs. The pattern is similar to C#‘s CancellationToken: you create an AbortController, pass its signal to an async operation, and call controller.abort() to cancel. For timeouts, use AbortSignal.timeout(ms) (Node.js 18+).

const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);

const response = await fetch(url, { signal: controller.signal });
// Throws AbortError on timeout

Context Propagation Across gRPC and HTTP Boundaries

In a microservice architecture, deadlines must propagate across network boundaries. Go’s gRPC implementation does this automatically — when you make a gRPC call with a context that has a deadline, the deadline is encoded in the gRPC metadata and the receiving server creates a context with the remaining time. HTTP does not have a standard header for this, so you must propagate deadlines manually (e.g., via a custom X-Deadline header).

// gRPC: deadline propagation is automatic
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: 42})
// The server receives a context with ~5s remaining (minus network latency)

Note: In HTTP-based microservices, a common pattern is to read the incoming request’s deadline, subtract the time already consumed, and forward the remaining time to downstream services. Without this, a 10-second top-level timeout can trigger cascading timeouts if each hop uses its own independent timeout.

Still Not Working?

Check for missing context propagation in middleware. If a middleware creates a new context but doesn’t copy the deadline:

// WRONG — creates new context, loses the original deadline
func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(context.Background(), key, value)  // Loses deadline!
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// CORRECT — add value to the existing context (preserves deadline)
func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), key, value)  // Inherits deadline
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Use pprof to find stuck goroutines:

import _ "net/http/pprof"

// Access http://localhost:6060/debug/pprof/goroutine?debug=2
// Shows all goroutine stack traces — find ones stuck on I/O
go http.ListenAndServe(":6060", nil)

Check for context.WithTimeout inside a loop. Creating a new context with timeout inside a tight loop can exhaust goroutines if the cancel functions are not called promptly:

// PROBLEMATIC — many contexts created, GC pressure
for _, item := range items {
    ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
    process(ctx, item)
    cancel()  // Must call cancel even after success
}

// BETTER — single context for the entire batch if appropriate
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
defer cancel()
for _, item := range items {
    if err := process(ctx, item); err != nil {
        return err
    }
}

Inspect context.AfterFunc (Go 1.21+). If you are registering cleanup callbacks with context.AfterFunc, verify they are not blocking. A blocking AfterFunc callback can delay resource cleanup and contribute to goroutine leaks.

For related Go issues, see Fix: Go Goroutine Deadlock, Fix: Go nil Pointer Dereference, Fix: Go Goroutine Leak, and Fix: gRPC Error Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles