context.WithTimeout is the version of WithCancel to use when "cancel when I say so" becomes "cancel if this takes longer than X". It wraps a parent context and starts a timer; when the timer fires, the new context is cancelled automatically and ctx.Err() reports context.DeadlineExceeded. This chapter covers how that timer behaves, why you still need defer cancel(), and how to race a slow operation against the timeout using select.
WithTimeout lives in the standard context package and has one shape:
You pass in a parent context and a time.Duration relative to now. You get back a child context and a cancel function. The duration is always measured from the moment of the call, never from some absolute clock time. If you write context.WithTimeout(parent, 2*time.Second) at 10:00:00.000, the context fires at 10:00:02.000. There is no "wall-clock 10:00:02"; it's purely "now + 2 seconds".
Under the hood, WithTimeout is a thin wrapper around WithDeadline. The library computes time.Now().Add(duration) and hands that absolute time to WithDeadline. For a relative window, WithTimeout reads better and you should prefer it.
Here is a first look at what cancellation by timeout actually delivers. The product search below is given 200 milliseconds, but the "search" sleeps for 500 milliseconds. The timeout wins.
The string context deadline exceeded is what context.DeadlineExceeded.Error() returns. We never see "found 12 products" because the ctx.Done() channel closes first, the select picks that case, and the function returns the context's error. We'll dig into that error value in a moment.
Both WithCancel and WithTimeout close ctx.Done(), and in both cases ctx.Err() is non-nil after closing. The difference is which error you get back.
| Trigger | ctx.Err() returns | Sentinel value |
|---|---|---|
You called the cancel function returned by any WithX | context.Canceled | context.Canceled |
| The timeout's timer fired | context.DeadlineExceeded | context.DeadlineExceeded |
Both are exported package variables. You compare against them with errors.Is, not with == directly on a wrapped error, because callers further up the stack often wrap the error with fmt.Errorf("...: %w", err).
The function returns a wrapped error, and errors.Is walks the %w chain to find context.DeadlineExceeded. If you had instead called cancel() manually before the 50ms elapsed, you'd hit the context.Canceled branch. Same Done() channel, different error, different user-facing message.
This distinction matters in real systems. A DeadlineExceeded usually means "the downstream service is overloaded, consider retrying with backoff or returning a 504". A Canceled typically means "the user navigated away or the parent context was torn down, don't retry, just clean up and exit". Treating them the same is a common bug.
defer cancel()If WithTimeout cancels itself when the timer fires, why does it return a cancel function at all? And why does every example call defer cancel()?
The short answer: the timer is a resource. Calling cancel() releases that resource immediately. Skipping it leaks the timer goroutine and the context node until the deadline naturally fires.
The longer answer comes from looking at what WithTimeout actually allocates. It creates a timerCtx struct, schedules a time.AfterFunc to fire at the deadline, and links the new context to its parent so that parent cancellation also cancels the child. If your operation finishes in 10 milliseconds but the timeout was 30 seconds, that timer sits in the runtime's heap for 30 seconds doing nothing useful. cancel() stops the timer, drops the parent link, and lets the garbage collector reclaim the context node.
go vet enforces this. If you write ctx, _ := context.WithTimeout(...) and discard the cancel, you get:
The idiomatic pattern is one line below the call:
Calling cancel() after the deadline already fired is harmless. The implementation is idempotent; a second call is a no-op. So defer cancel() is safe whether or not the timeout actually triggered.
Cost: Skipping defer cancel() keeps the underlying timer alive until the duration elapses, even if your operation finished in microseconds. For a 30-second timeout on a fast path, that's a 30-second goroutine and context allocation per call. At any real traffic level, this becomes a noticeable memory and scheduler tax.
A common mistake is to think defer cancel() somehow shortens the timeout. It doesn't. The deferred cancel runs when the surrounding function returns. If the function returns at 1 second into a 30-second timeout, the deferred call cleans up the timer 29 seconds early. If the function is still running when the timer fires, the timer fires on its own, and the deferred cancel later does nothing harmful.
The lookup finishes in 20ms, the function returns, the deferred cancel() fires, and the 5-second timer is released roughly 4.98 seconds early. Multiply by every request your service handles and the savings add up.
The pattern you'll write most often with WithTimeout is a select between two channels: one for the operation's result, one for ctx.Done(). Whichever fires first wins.
The sequence shows the race in time order. The caller sets the timer, spawns a worker that calls a slow API, and then blocks in a select. The timer fires before the API responds, so the select picks ctx.Done() and the caller returns the timeout error. The API eventually responds, but by then nobody is reading the result channel.
Here is that pattern in code. We model an external product-pricing API that sometimes responds quickly and sometimes drags.
The first call has a 50ms latency, well under the 150ms timeout, and returns the price. The second call has a 300ms latency, the timer fires at 150ms, and select picks ctx.Done(). Two key details:
resultCh is buffered with size 1. Even though nobody reads from it after the timeout fires, the goroutine can still send and exit cleanly. With an unbuffered channel, the goroutine would block forever on the send because the reader is gone. That is a leak.cancel() after each iteration. Inside the loop you can't easily use defer because deferrals stack up until the surrounding function returns, so for loop-scoped contexts call cancel() directly.Cancelling a context cancels waiting, not work. A context can't reach inside a goroutine and stop it. The goroutine has to cooperate by checking ctx.Done() periodically or by passing the context to lower-level functions that already check it (like net/http's Request.WithContext).
What's wrong with this code?
The caller returns at 300ms with a timeout error, which looks correct from the outside. But computeRecommendations never sees the context. It sleeps for two full seconds and then sends on the result channel. The goroutine kept running, kept consuming CPU and memory, and the work it did is thrown away.
In a real service that gets thousands of requests per second, every timed-out request leaves a goroutine running. They pile up until the work happens to complete, by which time many more have started. This is one of the classic causes of "we set a timeout and the server still fell over".
Fix: Thread the context into the worker and check ctx.Done() from inside it. If the work is a single long blocking call (like a network read), use the context-aware version of that call instead.
The worker still sees the cancellation, exits at the next stage boundary, and frees its resources. The result is the same from the caller's view, but no goroutine is left running in the background.
Cost: A goroutine that does not check ctx.Done() will run to completion even after its caller has given up. Each such goroutine costs at least a few kilobytes of stack plus whatever it touches; under load, leaked goroutines are a frequent root cause of memory growth and OOM crashes.
Setting a timeout does not stop slow work. It stops waiting for slow work. To actually stop the work, the operation has to be cancellation-aware. Standard library functions in net/http, database/sql, and os/exec all take a context.Context for exactly this reason.
Putting the pieces together, here is the timeline a typical WithTimeout call follows when the operation is slower than the budget. The timer cascades cancellation down through any child contexts derived from the same parent.
The diagram shows the five moments worth knowing. At t=0, WithTimeout schedules a timer. Sometime later, your code launches a worker that races the timeout. At t=200ms the timer fires, which is the event that closes ctx.Done() and sets ctx.Err() to context.DeadlineExceeded. The waiting select picks the Done() case and returns. Finally, when the surrounding function returns, the deferred cancel() runs. It's a no-op at this point because the timer already fired, but it costs nothing.
The same shape applies when the operation finishes early. The select picks the result case at, say, t=80ms, the function returns its result, and the deferred cancel() releases the timer at t=80ms instead of letting it run until t=200ms. That early release is the whole reason cancel is given to you as a function rather than being baked in.
When you create a WithTimeout child from another WithTimeout parent, the effective deadline is the earlier of the two. The standard library handles this for you, but understand the model.
The child asked for 2 seconds but inherits its parent's earlier deadline of 500ms. When the parent's timer fires, the parent cancels, and cancellation cascades to the child. The child's own 2-second timer never gets to fire on its own; it's released as part of the parent's cancellation.
This is exactly the behavior you want when an HTTP handler has a 30-second deadline and calls a function that wants a 5-second timeout on each downstream call. The 5-second timeout wins for fast paths, but if the handler is already at 28 seconds, the next downstream call sees an effective budget of 2 seconds and bows out early. You almost never want a child to wait longer than its parent.
Use WithTimeout when you have a relative budget: "this call gets at most 200 milliseconds". If you have an absolute moment in time, like "this request must finish before 12:00:00 UTC", use context.WithDeadline(parent, t time.Time). Internally, WithTimeout calls WithDeadline(parent, time.Now().Add(d)).
10 quizzes