The problem with hanging requests
You are running a web server. A client sends a request to fetch a report. Your handler calls a database, a cache, and an external API. The database returns fast. The cache hits. The external API is slow. It has a network glitch. It retries. It retries again. Your handler is blocked. The goroutine is stuck. The client waits. The client waits longer. The client gives up and closes the connection. Your server doesn't know. The goroutine is still running. It holds the database connection. It holds memory. One slow request is manageable. A thousand slow requests crash the server. You need a mechanism to force the operation to stop when time runs out.
What WithDeadline actually does
context.WithDeadline creates a context that carries a hard stop time. You pass a parent context and a time.Time value. The function returns a new context and a cancel function. The new context is linked to the parent. When the deadline arrives, the context signals that it is done. Any code waiting on the context wakes up and sees the signal. The context does not kill goroutines. It sends a message. Your code must listen to the message and exit.
Convention aside: context.Context always goes as the first parameter. The community names it ctx. This makes it easy to spot functions that can be interrupted. Functions that take a context should respect cancellation and deadlines.
Deadlines are absolute. The clock doesn't care about your logic.
Minimal example
Here is the simplest pattern. Create the deadline, defer the cancel, check the context.
package main
import (
"context"
"fmt"
"time"
)
// main demonstrates a basic deadline context.
func main() {
// Set a deadline 2 seconds from now.
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
// Defer cancel to stop the timer if the work finishes early.
defer cancel()
// Wait for either the work to complete or the deadline to pass.
select {
case <-time.After(1 * time.Second):
fmt.Println("Work finished before deadline")
case <-ctx.Done():
fmt.Println("Work stopped because deadline passed")
}
}
Defer cancel. Always.
How the runtime handles the timer
When you call context.WithDeadline, the runtime creates a new context node linked to the parent. It also starts a timer in the background. The timer is set to fire at the deadline time. If the timer fires, the context's Done channel closes. Any goroutine waiting on ctx.Done() wakes up and sees the context is done.
If your work finishes before the deadline, you call cancel(). This stops the timer immediately. If you skip calling cancel(), the timer keeps running until the deadline hits. The context and its internal timer stay in memory until then. This is a resource leak. The leak is small per call, but under high load, thousands of leaked timers add up.
The parent context can cancel the child. The child cannot cancel the parent. This forms a tree. When a parent cancels, all children cancel. When a child cancels, the parent keeps running.
Trust gofmt. Argue logic, not formatting. Run the formatter before you commit. Most editors run it on save.
Realistic example
Here is how a handler uses a deadline for a downstream call. The worker must check the context to respect the deadline.
// simulateNetworkCall performs work that respects context cancellation.
func simulateNetworkCall(ctx context.Context) error {
// Wait for either the work to finish or the context to cancel.
select {
case <-time.After(3 * time.Second):
return nil
case <-ctx.Done():
// Return the context error to distinguish deadline from other errors.
return ctx.Err()
}
}
The caller sets the deadline and handles the result.
package main
import (
"context"
"fmt"
"time"
)
// main demonstrates a realistic deadline scenario.
func main() {
// Parent context represents the incoming request.
reqCtx, reqCancel := context.WithCancel(context.Background())
defer reqCancel()
// Set a hard deadline 1 second from now for the downstream call.
deadline := time.Now().Add(1 * time.Second)
callCtx, callCancel := context.WithDeadline(reqCtx, deadline)
defer callCancel()
// Start the work in a goroutine.
errCh := make(chan error, 1)
go func() {
errCh <- simulateNetworkCall(callCtx)
}()
// Wait for the result.
select {
case err := <-errCh:
if err != nil {
fmt.Println("Operation failed:", err)
} else {
fmt.Println("Operation succeeded")
}
case <-time.After(2 * time.Second):
fmt.Println("Main goroutine gave up waiting")
}
}
Context flows down. Errors bubble up.
Respecting the deadline in your code
context.WithDeadline is passive. It signals. It does not force exit. Your code must check ctx.Done(). If you ignore the context, the deadline does nothing. The goroutine keeps running. The timer fires. The channel closes. Your code doesn't look. The leak continues.
Use select to wait on the context alongside other operations. Use ctx.Err() to check why the context ended. ctx.Err() returns nil if the context is still active. It returns context.DeadlineExceeded if the deadline passed. It returns context.Canceled if the parent canceled.
The boilerplate if err != nil { return err } is standard. It makes the error path visible. Don't try to hide it. The community accepts the verbosity because it prevents silent failures.
Pitfalls and compiler errors
The most common mistake is forgetting to call the cancel function. If you return early from a function, the timer keeps running. The compiler won't catch this. You get a runtime resource leak.
Another mistake is passing the wrong type. WithDeadline needs an absolute time. WithTimeout takes a duration. If you try to pass a time.Duration where a time.Time is expected, the compiler rejects it with cannot use 5 * time.Second (untyped constant) as time.Time value in argument. Don't mix them up.
Check ctx.Err() to distinguish between a deadline exceeded and a network error. The error value is context.DeadlineExceeded. You can compare it directly. If you wrap errors, use errors.Is(err, context.DeadlineExceeded).
The timer is a resource. Release it.
Choosing the right context function
Pick the tool that matches your time constraint.
Use context.WithDeadline when you need to synchronize multiple operations to a single absolute time, such as a batch job that must finish by midnight.
Use context.WithTimeout when you care about the duration of a single operation, like a database query that should not take longer than 500 milliseconds.
Use context.WithCancel when you need manual control over cancellation, such as stopping a background worker when a user closes a connection.
Use context.Background() when you are at the top level of your application and need a root context for new request trees.
Pick the context tool that matches your time constraint.