The deadline vs timeout trap
You are building a service that fetches user data, calls a payment gateway, and updates a cache. The whole operation must finish in two seconds, or the user gets a timeout error. You wrap the top-level handler in context.WithTimeout. It works. Then you refactor. You split the work into three helper functions. Each helper needs the context. You pass the context down. Now you realize each helper is calling WithTimeout again, or you are manually calculating remaining time. The deadline gets lost. The context API offers two ways to set a limit: WithTimeout and WithDeadline. They look similar, but one handles composition while the other fights it.
Go's context package does not distinguish between a timeout and a deadline at runtime. Both functions create the exact same underlying context type. The difference exists only at the creation site. WithTimeout is a convenience wrapper that converts a relative duration into an absolute deadline. WithDeadline accepts the absolute time directly. Once the context is created, you cannot tell which function produced it. Both contexts expose the same Deadline() method, the same Done() channel, and the same Err() behavior. The choice between them is about how you reason about time in your codebase.
How the context tracks time
A context with a deadline holds an absolute time.Time value and a timer. When you call WithTimeout(parent, 5*time.Second), the function calls time.Now().Add(5 * time.Second) internally and passes that result to WithDeadline. The context stores the absolute deadline and starts a timer that fires when the current time reaches that point. When the timer fires, the context closes its Done channel and sets its error to context.DeadlineExceeded. Any goroutine waiting on ctx.Done() wakes up and sees the error.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// WithTimeout calculates the deadline from the current moment.
// It is sugar for WithDeadline(parent, time.Now().Add(d)).
ctxTimeout, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// WithDeadline accepts an absolute time directly.
// The caller is responsible for calculating the target time.
deadline := time.Now().Add(2 * time.Second)
ctxDeadline, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
// Both contexts behave identically after creation.
// They share the same interface and runtime behavior.
fmt.Println(ctxTimeout.Err() == ctxDeadline.Err()) // prints: true
}
The cancel function is part of the return value. Calling cancel() stops the timer and closes the Done channel immediately, even if the deadline has not passed. This allows early cancellation. If you forget to call cancel(), the timer keeps running until the deadline, and the context object remains in memory. Goroutines holding a reference to the context cannot be garbage collected. Always call cancel() when the context is no longer needed. The convention is to call defer cancel() immediately after creation.
Context is plumbing. Run it through every long-lived call site.
The composition problem
The real difference between WithTimeout and WithDeadline appears when you compose functions. Imagine a call chain: HandleRequest calls FetchData, which calls EnrichData. HandleRequest sets a 5-second timeout. FetchData has already spent 3 seconds. EnrichData needs to call an external service. How long should EnrichData wait?
If you use WithTimeout everywhere, you have to do the math manually. EnrichData must query the parent context for the remaining time, subtract any overhead, and create a new timeout. If you get the math wrong, you might allow the total operation to exceed the original limit, or you might cancel too early. The context API does not expose "remaining time" directly. You have to calculate it from the deadline.
func enrichData(ctx context.Context) error {
// Query the deadline to calculate remaining time.
// The bool indicates whether a deadline exists.
deadline, ok := ctx.Deadline()
if !ok {
// No deadline set. Use a default timeout for safety.
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 10*time.Second)
defer cancel()
} else {
// Calculate how much time is left until the deadline.
remaining := time.Until(deadline)
if remaining <= 0 {
return ctx.Err()
}
// Use the remaining time for the sub-task.
// This respects the original deadline automatically.
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, remaining)
defer cancel()
}
// Perform work with the derived context.
return callExternalService(ctx)
}
This pattern works, but it is verbose. Every layer must check for a deadline, calculate remaining time, and create a new timeout. If you use WithDeadline at the root, the deadline is baked into the context. Child functions can pass the context down without modification. The deadline propagates automatically. When EnrichData needs to set a limit, it can derive a new context with the same deadline, or it can calculate remaining time once and reuse it. The absolute deadline is a single source of truth. Relative timeouts require arithmetic at every boundary.
Use context.WithDeadline when you are composing functions and need to respect an existing deadline without doing manual arithmetic. Use context.WithTimeout when you are at the root of a call tree and want to limit the total operation to a fixed duration from now.
Querying remaining time
The Deadline() method returns the absolute deadline and a boolean. The boolean is true if a deadline is set. If no deadline exists, the boolean is false and the time value is zero. This allows functions to adapt their behavior based on the context. A function might skip expensive work if the remaining time is too short. It might fall back to a cached result. It might return early to avoid blocking.
func processWithFallback(ctx context.Context) (Result, error) {
// Check if a deadline exists and how much time remains.
deadline, ok := ctx.Deadline()
if ok {
remaining := time.Until(deadline)
// If less than 100ms remains, skip the slow path.
if remaining < 100*time.Millisecond {
return getCachedResult(), nil
}
}
// Perform the main work.
// The context will cancel this if the deadline passes.
return doExpensiveWork(ctx)
}
This pattern is common in high-performance services. You do not want to start a slow operation if the request is already about to time out. Checking Deadline() lets you make that decision. The context does not block. It signals. You must listen to the signal and act on it. The Done() channel is the signal. select statements wait on the channel. When the channel closes, the case <-ctx.Done() branch executes. This is how goroutines respond to cancellation.
func waitForResult(ctx context.Context, ch <-chan Result) (Result, error) {
select {
// ctx.Done() closes when the context is cancelled or deadline passes.
case <-ctx.Done():
return Result{}, ctx.Err()
case res := <-ch:
return res, nil
}
}
The worst context bug is the one that never cancels.
Pitfalls and leaks
Contexts are cheap to create, but they hold resources. A context with a deadline holds a timer. If you create a context and never call cancel(), the timer keeps running. The context object stays in memory. Any goroutine holding a reference to the context also stays alive. This is a memory leak. The leak is often subtle. You might create a context in a loop, pass it to a goroutine, and forget to cancel it. The goroutine finishes quickly, but the context timer keeps running until the deadline. If the loop runs thousands of times, you accumulate thousands of timers.
// BAD: cancel is never called. The timer leaks.
func leakyLoop(items []Item) {
for _, item := range items {
ctx, _ := context.WithTimeout(context.Background(), 1*time.Second)
go process(ctx, item)
}
}
// GOOD: cancel is called immediately after spawning the goroutine.
func safeLoop(items []Item) {
for _, item := range items {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
go func() {
defer cancel()
process(ctx, item)
}()
}
}
The compiler does not check for context leaks. You must manage the lifecycle yourself. The cancel function is idempotent. Calling it multiple times is safe. The convention is to call defer cancel() in the same scope where the context is created. If you create a context in a loop, you cannot use defer because defer runs when the function returns, not when the loop iteration ends. You must call cancel() explicitly, or wrap the loop body in a function or goroutine where defer works.
Another pitfall is passing nil as the parent context. The context package allows nil as a parent, treating it like context.Background(). This is a holdover from early Go versions. The modern convention is to use context.Background() at the entry point and context.TODO() when you do not have a context yet but know you need one. Passing nil works, but it signals that you are ignoring the context chain. Use Background() for roots. Use TODO() as a placeholder.
If you pass the wrong type to WithTimeout, the compiler rejects the program with cannot use x (variable of type time.Time) as time.Duration value in argument. The signature requires a time.Duration, not a time.Time. This is a common mistake when switching between WithTimeout and WithDeadline. WithTimeout takes a duration. WithDeadline takes a time. The compiler catches this error immediately.
When to use which
The decision between WithTimeout and WithDeadline depends on your mental model of time and the structure of your code. Both functions produce identical contexts. The difference is purely at the creation site. Pick the one that reduces boilerplate and prevents errors.
Use context.WithTimeout when you are at the root of a call tree and want to limit the total operation to a fixed duration from now. Use context.WithDeadline when you are composing functions and need to respect an existing deadline without doing manual arithmetic. Use context.WithDeadline when you need to synchronize multiple goroutines to stop at the exact same wall-clock time. Use context.WithTimeout when the absolute time does not matter and relative duration is the only constraint. Use context.WithDeadline when you are writing a library function that accepts a context and needs to derive a child context with the same deadline. Use context.WithTimeout when you are writing a test that needs a short, predictable timeout for a specific assertion.
Deadlines are absolute. Timeouts are relative. Pick the one that matches your mental model of time.