Skip to content
gohacks.dev
Concurrency

Context Deadline Exceeded in Go and How to Track It Down

Track down context deadline exceeded in Go: whose deadline fired, net/http message variants, nested budgets, retry loops and logging the time left.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

context deadline exceeded is the error that appears in every Go service that talks to anything slow. It is honest and unhelpful: time ran out. Whose time, spent where, and was it the right amount, the message does not say. The fix is rarely "raise the timeout", because the timeout is usually a symptom: a slow dependency, a retry loop that burned the budget, or a deadline set far upstream that nobody remembers.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This page lists the places the error comes from, how to find which deadline fired, and the patterns that cause it. It belongs to the concurrency material and builds on timeouts and deadlines and the cancelled-context error. Everything ran on Go 1.25.6.

What it means

A deadline is a point in time. A context carries at most one effective deadline, the earliest in its chain, and when the clock reaches it the context's Done channel closes and Err() returns context.DeadlineExceeded. Anything watching the context then stops and reports that error. The message is the same whether the deadline was a per-call timeout, the whole request's budget or an http.Client setting.

main.goGo
package main

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

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()

	select {
	case <-time.After(time.Second):
		fmt.Println("finished")
	case <-ctx.Done():
		err := ctx.Err()
		fmt.Println(err, errors.Is(err, context.DeadlineExceeded))
	}
}
Text
context deadline exceeded true

The operation would take a second and the deadline is 20 ms, so the error fires. In real code the interesting questions are which of several deadlines this was and why the operation was slow.

The message variants

The net/http client wraps the error with the stage it was in, which is useful:

main.goGo
package main

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

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(300 * time.Millisecond)
	}))
	defer srv.Close()

	// 1. A client-level timeout.
	c := &http.Client{Timeout: 50 * time.Millisecond}
	_, err := c.Get(srv.URL)
	fmt.Println("client timeout:", errors.Is(err, context.DeadlineExceeded))
	fmt.Println(trim(err.Error()))

	// 2. A context deadline on the request.
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
	req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL, nil)
	_, err = http.DefaultClient.Do(req)
	fmt.Println("context deadline:", errors.Is(err, context.DeadlineExceeded))
	fmt.Println(trim(err.Error()))
}

// trim hides the random port so the output is stable.
func trim(s string) string {
	for i := 0; i+9 < len(s); i++ {
		if s[i:i+9] == "127.0.0.1" {
			j := i + 9
			for j < len(s) && s[j] != '"' {
				j++
			}
			return s[:i] + "127.0.0.1:PORT" + s[j:]
		}
	}
	return s
}
Text
client timeout: true
Get "http://127.0.0.1:PORT": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
context deadline: true
Get "http://127.0.0.1:PORT": context deadline exceeded

The client's own timeout produces Get "http://127.0.0.1:PORT": context deadline exceeded (Client.Timeout exceeded while awaiting headers), naming the stage (awaiting headers; there are others such as connecting and reading the body). A context deadline on the request produces the plain context deadline exceeded with the URL. In both, errors.Is(err, context.DeadlineExceeded) is true, which is the check to use (Is and As). The variants tell you where the time went: "awaiting headers" means the server accepted the request and did not answer in time, which points at the server's work; a dial error means the connect never completed, which points at the network or a dead host (timeouts in net code).

Which deadline fired?

When a request flows through several layers, each may set its own timeout, and a deadline in an outer layer wins over a longer one in an inner layer. The question to answer is "what was the effective deadline and who set it". Two tools:

main.goGo
package main

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

func inner(ctx context.Context) error {
	if dl, ok := ctx.Deadline(); ok {
		fmt.Println("inner sees", time.Until(dl).Round(10*time.Millisecond), "left")
	}
	// The inner layer asks for 500ms, but the outer budget is shorter.
	ctx, cancel := context.WithTimeoutCause(ctx, 500*time.Millisecond, errors.New("inner: backend call"))
	defer cancel()

	<-ctx.Done()
	return context.Cause(ctx)
}

func main() {
	ctx, cancel := context.WithTimeoutCause(context.Background(), 60*time.Millisecond,
		errors.New("outer: whole request budget"))
	defer cancel()

	fmt.Println("expired because:", inner(ctx))
}
Text
inner sees 60ms left
expired because: outer: whole request budget

The inner layer asks for 500 ms and logs how much time actually remains (about 60 ms). When the deadline fires, context.Cause reports outer: whole request budget: the outer budget expired first, even though the inner timeout was the one being waited on. Without causes, both would report the same plain error, and a developer looking at the inner code would conclude the backend call was slow when the request had already used up its budget elsewhere (context cause). Logging time.Until(deadline) at the start of every outbound call is the cheapest instrumentation there is: when the number is already small on entry, the delay was upstream.

Common causes, and fixes

1. A slow dependency

The simplest cause: the thing you called was slow. A database query without an index, an overloaded service, a cold cache. The deadline did its job by cutting the wait. Fix the dependency (the query plan, the capacity), or accept a degraded answer when it times out (a cached value, a default). Raising the timeout hides the problem and ties up goroutines and connections for longer.

2. Sequential calls spending a shared budget

Three calls in a row, each fine individually, can exhaust a budget the last never saw. If a request has 800 ms and calls A, B and C in sequence taking 300 ms each, C fails. Run independent calls in parallel (an errgroup), and give each its share of what remains (the budget example shows the arithmetic).

3. Retries inside a spent budget

A retry loop that ignores the parent deadline keeps starting attempts that cannot finish:

main.goGo
package main

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

func call(ctx context.Context) error {
	select {
	case <-time.After(40 * time.Millisecond):
		return errors.New("transient failure")
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	attempts := 0
	for {
		attempts++
		err := call(ctx)
		if err == nil {
			break
		}
		if ctx.Err() != nil { // the budget is gone: stop, don't retry
			fmt.Println("gave up after", attempts, "attempts:", err)
			break
		}
	}
}
Text
gave up after 3 attempts: context deadline exceeded

Each attempt takes 40 ms, so the third attempt is cut off by the deadline at 100 ms, and the loop stops because ctx.Err() is non-nil. Without the check, the loop would keep calling a function that returns immediately with the same error, spinning. Retries should check the context before every attempt and before every sleep (retryable errors).

4. A timeout shorter than the work needs

Sometimes the timeout is just too small: a report that takes 3 seconds under a 2-second limit. Measure the actual distribution (p99, not the average), set the budget above it with headroom, and monitor how close real calls get. A timeout is a statement about an acceptable wait, so decide it from the user's perspective and not from the median.

5. The client's own deadline

If a caller sends a deadline (a gRPC deadline, a header) and the request arrives with 50 ms left, every call you make will fail with this error through no fault of yours. Check the remaining time on entry, and when it is below a floor return a fast failure instead of starting work that cannot finish.

6. Time spent before the call

A deadline starts at creation, not at the call. If a request waits in a queue for 900 ms before a worker starts it, and the context was created at enqueue time with a 1-second timeout, the work has 100 ms. Create the context when the work starts, or account for queue time deliberately.

What to log

When you handle a deadline error, log enough to diagnose it later: the operation name, the configured timeout, the time remaining at the start, the elapsed time and context.Cause(ctx). Count them by operation, so the dashboard shows which dependency is timing out. And treat the rate as a signal: a rising count of deadline errors on one call is a slow dependency before it is an outage. The package behaviour is defined in the context.DeadlineExceeded documentation, which notes that the error implements Timeout() bool returning true, and the Go blog's Context article explains why deadlines propagate down a call tree. For the related errors, see the cancellation error and timeouts in net code.

Also inNetworking
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.