Skip to content

Fix: Go context deadline exceeded / context canceled

FixDevs ·

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:

  • 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)
    }()
}

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)

For related Go issues, see Fix: Go Goroutine Deadlock and Fix: Go nil Pointer Dereference.

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