Fix: Go context deadline exceeded / context canceled
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 exceededOr cancellation propagation:
err := http.Get(url)
// err: Get "https://api.example.com": context canceledOr the error wraps deeper:
rpc error: code = DeadlineExceeded desc = context deadline exceeded
dial tcp 10.0.0.1:5432: i/o timeoutWhy 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 all —
http.Clientwith 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 leakCommon 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 exceededLog 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Go Goroutine Leak — Goroutines That Never Exit
How to find and fix goroutine leaks in Go — detecting leaks with pprof and goleak, blocked channel patterns, context cancellation, and goroutine lifecycle management.
Fix: Go panic: runtime error: invalid memory address or nil pointer dereference
How to fix Go nil pointer dereference panics — checking for nil before use, nil interface traps, nil map writes, receiver methods on nil, and defensive nil handling patterns.
Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected
How to fix Go testing issues — test function naming, table-driven tests, t.Run subtests, httptest, testify assertions, and common go test flag errors.
Fix: Spring Boot @Cacheable Not Working — Cache Miss Every Time or Stale Data
How to fix Spring Boot @Cacheable issues — @EnableCaching missing, self-invocation bypass, key generation, TTL configuration, cache eviction, and Caffeine vs Redis setup.