- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team I worked with had a checkout endpoint that the frontend timed out at three seconds. The handler set its own three-second timeout. The payment client wrapped that with a one-second timeout. The database call inside the payment client wrapped that with a five-hundred-millisecond timeout. Every layer felt safe. The code review was clean.
In production, the endpoint started returning context deadline exceeded from the database call at random, sometimes after 200ms and sometimes after 480ms, never the same number twice. The team spent a week chasing a phantom slow query that did not exist.
The slow query was not the bug. The bug was context.WithTimeout.
What WithTimeout actually does
Open the standard library context package. The signature is the giveaway:
func WithTimeout(
parent Context,
timeout time.Duration,
) (Context, CancelFunc)
You pass a duration. The implementation, in the stdlib source, is one line of substance:
func WithTimeout(
parent Context,
timeout time.Duration,
) (Context, CancelFunc) {
return WithDeadline(
parent,
time.Now().Add(timeout),
)
}
WithTimeout is a one-line wrapper around WithDeadline. It calls time.Now() at the moment you invoke it and adds your duration. That gives you an absolute deadline.
Then WithDeadline runs (simplified from the stdlib — the real implementation has more guards around cancellation and timer wiring):
func WithDeadline(
parent Context, d time.Time,
) (Context, CancelFunc) {
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The parent deadline is already sooner than ours.
// Return a context whose deadline equals the parent's.
return WithCancel(parent)
}
// ... build the timer for our deadline
}
If the parent has a deadline that is already earlier than the one you just computed, your "5 seconds" is silently ignored. The returned context inherits the parent's earlier deadline. The duration you passed is ignored at runtime.
This is correct behavior. The earliest deadline anywhere on the path from root to leaf is the one that wins. The standard library is doing exactly what the docs promise.
The trap is that the duration in your code reads like a guarantee. You wrote WithTimeout(ctx, 5*time.Second). You think you have five seconds. You have whatever the parent left you.
The nested-timeout pattern that breaks
Back to the checkout endpoint. Here is the simplified shape:
func handleCheckout(
w http.ResponseWriter,
r *http.Request,
) {
ctx, cancel := context.WithTimeout(
r.Context(), 3*time.Second,
)
defer cancel()
if err := chargePayment(ctx, ...); err != nil {
http.Error(w, err.Error(), 500)
return
}
}
func chargePayment(
ctx context.Context, ...
) error {
// "Be nice, leave headroom for the rest."
payCtx, cancel := context.WithTimeout(
ctx, 1*time.Second,
)
defer cancel()
return paymentDB.Insert(payCtx, ...)
}
func (p *paymentDB) Insert(
ctx context.Context, ...,
) error {
// "Database calls should be fast."
qCtx, cancel := context.WithTimeout(
ctx, 500*time.Millisecond,
)
defer cancel()
_, err := p.conn.ExecContext(qCtx, ...)
return err
}
Three WithTimeout calls. Each one feels defensive. Each one was reviewed.
Now imagine the handler took 700ms doing other work — auth, validation, fraud checks — before reaching chargePayment. The handler's three-second deadline is now 2.3 seconds away. That is fine.
Then chargePayment says one second. That is also fine: 1.0 < 2.3.
Then Insert says 500ms. Also fine.
Now imagine the same flow on a slower request. The handler took 2.6 seconds before chargePayment. The handler's deadline is now 400ms away. chargePayment says one second, but WithTimeout silently caps it at 400ms. Insert then says 500ms, but the parent deadline (the handler's, propagated through) caps it at whatever is left of that 400ms window — less than 400ms, because some of it has already been spent inside chargePayment itself. The query had 500ms in the code and a moving target at runtime, and nobody knew.
The visible symptom is context deadline exceeded from the database driver, with timing that does not match any timeout in the code. The actual budget is whatever is left of the request, not what the function declared.
Why unit tests do not catch it
This is the painful part. You can write a unit test for Insert that passes a context.Background() with no deadline and verifies the 500ms timeout fires. It will. Green tick. Ship it.
You can write an integration test for the handler that runs the whole stack on a fast laptop. The deadline is never the bottleneck. Green tick. Ship it.
The bug only appears when the parent deadline is closer than the child timeout. Both layers have to be running, both have to be timed in production-like conditions, and the parent has to have eaten enough of its budget that the child's static duration is larger than what remains. None of that is in your test fixture.
You can construct the test:
func TestInsertRespectsParentDeadline(t *testing.T) {
parent, cancel := context.WithTimeout(
context.Background(),
100*time.Millisecond,
)
defer cancel()
start := time.Now()
err := db.Insert(parent, /* args */)
elapsed := time.Since(start)
if elapsed > 200*time.Millisecond {
t.Fatalf(
"wanted ~100ms, got %v",
elapsed,
)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("wanted deadline, got %v", err)
}
}
You can write that test. People do not. The bug lives between the timeout you wrote and the budget you actually have, and no single function owns that distance.
The fix: one deadline at the entry point
The shape that does not break is to set a deadline once, at the layer that owns the SLA, and stop adding timeouts below that.
func handleCheckout(
w http.ResponseWriter,
r *http.Request,
) {
deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(
r.Context(), deadline,
)
defer cancel()
if err := chargePayment(ctx, ...); err != nil {
http.Error(w, err.Error(), 500)
return
}
}
func chargePayment(
ctx context.Context, ...,
) error {
return paymentDB.Insert(ctx, ...)
}
func (p *paymentDB) Insert(
ctx context.Context, ...,
) error {
_, err := p.conn.ExecContext(ctx, ...)
return err
}
WithDeadline takes an absolute time. It does not change meaning based on where it is called. The handler establishes the budget. Every downstream call sees the same hard wall. If the database query needs to know how long it has, it asks ctx.Deadline() and gets the truth. The function does not have to guess based on a duration it wrote down before it knew what was left.
Reading the deadline inside Insert looks like this:
if d, ok := ctx.Deadline(); ok {
remaining := time.Until(d)
log.Printf("query has %v left", remaining)
}
When a layer genuinely needs a tighter bound (a circuit breaker, a fail-fast retry, a per-call cap that protects a connection pool), make it explicit and use WithDeadline again with an absolute time you computed from the parent's remaining budget. Not a fixed duration. Something like:
if d, ok := ctx.Deadline(); ok {
remaining := time.Until(d)
if remaining > 200*time.Millisecond {
// min(...) requires Go 1.21+
budget := remaining / 2
if budget > 500*time.Millisecond {
budget = 500 * time.Millisecond
}
tight := time.Now().Add(budget)
ctx, cancel := context.WithDeadline(
ctx, tight,
)
defer cancel()
_ = ctx
}
}
The duration is derived from what is left. The deadline is absolute. The behavior does not depend on how much the parent already spent.
When WithTimeout is fine
WithTimeout is not broken. It is fine at the top of a tree, where there is no parent deadline to interfere. Background workers, cron jobs, the entry point of a server handler — anywhere r.Context() or context.Background() is the parent and you are setting the first deadline.
The rule is small. WithTimeout at roots. WithDeadline everywhere else. If WithTimeout shows up mid-call-graph, the parent has either already protected you or is about to silently override you. Neither case wants the line.
The deadline is the one budget. Pass it down and trust it.
If this saved you a debugging session
Context behavior is one of those parts of Go that pays for itself the first time you internalize it. The whole language is like that — once goroutines, channels, and the context tree sit in your head as a small set of rules that compose, the rest of Go stops being surprising. The Complete Guide to Go Programming walks through that mental model end to end, with runnable examples you can drop into a service the same afternoon.
If your day job is shipping Go alongside an AI coding assistant, Hermes IDE is the editor I build for that workflow. It is built for the loop where the AI is reading and editing your Go code with you, not at you.

Top comments (0)