The hanging request
You build a web service that fetches user profiles. Most requests take 50 milliseconds. One day, a downstream database starts locking up. Your server's goroutines pile up waiting for a response. Memory fills. The process crashes. The user sees a 502 error.
The problem wasn't the logic. The problem was waiting forever for a response that never came. Go solves this with timeouts. A timeout forces an operation to stop after a set duration, freeing resources and returning a clear error.
Context as a cancellation signal
Go enforces timeouts via context.WithTimeout. The context package carries deadlines, cancellation signals, and request-scoped values across API boundaries. A timeout is a deadline. When the deadline passes, the context signals every goroutine holding it to stop what it's doing and clean up.
A context acts like a walkie-talkie channel shared by a team. The leader sets a timer. When the timer hits zero, the leader broadcasts abort. Every team member listening to that channel hears the signal and drops their current task. The context is the channel. The timeout is the timer.
Convention dictates context.Context is the first parameter in any function that can block, named ctx. This makes it easy to spot and allows tools to analyze context flow. Functions that accept a context must respect cancellation and deadlines. If a function takes a context and ignores it, the timeout is useless.
Minimal timeout pattern
Here's the minimal pattern: create a timeout context, pass it to the worker, and check the error.
package main
import (
"context"
"fmt"
"time"
)
// fetchData simulates a slow operation that respects context cancellation.
// The function blocks until data arrives or the context signals abort.
func fetchData(ctx context.Context) (string, error) {
// select waits for the first ready channel among the cases.
// It picks randomly if multiple are ready, but here only one can fire.
select {
case <-time.After(2 * time.Second):
// time.After creates a timer and returns a channel that fires once.
// This branch wins only if the context deadline hasn't passed yet.
return "data", nil
case <-ctx.Done():
// ctx.Done() returns a channel that closes when the deadline expires.
// Reading from a closed channel returns immediately with the zero value.
return "", ctx.Err()
}
}
func main() {
// Create a context with a 1-second deadline.
// 1 second forces the operation to fail fast if it drags.
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
// cancel releases the timer resource.
// defer ensures cancel runs even if main returns early due to an error.
defer cancel()
result, err := fetchData(ctx)
if err != nil {
fmt.Println("Operation failed:", err)
return
}
fmt.Println("Got result:", result)
}
The timer starts the moment you call WithTimeout. Don't create contexts inside tight loops.
What happens at runtime
When you call context.WithTimeout, Go spawns a background timer. The returned context holds a channel that closes when the timer fires. The cancel function stops the timer and closes the channel immediately if you call it early.
fetchData enters a select. It waits on two channels. time.After fires after 2 seconds. ctx.Done() fires after 1 second. The select picks the ready channel. Since 1 second is less than 2 seconds, ctx.Done() wins. The function returns ctx.Err(), which is context.DeadlineExceeded. The caller sees the error. The defer cancel() runs, cleaning up the timer.
If you call cancel() before the deadline, the channel closes early. This lets you abort work manually. The context supports both automatic deadlines and manual cancellation.
Select is the engine of cancellation. Without select, context is just a struct.
Realistic HTTP handler
In production, timeouts live in HTTP handlers or database wrappers. Here's how a handler protects itself from a slow dependency.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// callExternalAPI simulates an HTTP client call that respects context.
// Real HTTP clients use ctx to abort the connection when the deadline passes.
func callExternalAPI(ctx context.Context) ([]byte, error) {
// select waits for the simulated response or the context cancellation.
// This pattern prevents the goroutine from blocking indefinitely.
select {
case <-time.After(3 * time.Second):
// Simulates a successful response after 3 seconds.
// In production, this would be the HTTP response body.
return []byte("response"), nil
case <-ctx.Done():
// The context deadline fired before the response arrived.
// ctx.Err() returns DeadlineExceeded to indicate the cause.
return nil, fmt.Errorf("call aborted: %w", ctx.Err())
}
}
// handleRequest demonstrates wrapping a handler with a timeout.
// The timeout protects the server from hanging goroutines caused by slow dependencies.
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Derive a timeout from the request context to respect client disconnects.
// 500ms hard limit prevents a slow downstream service from holding the HTTP goroutine.
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
// defer ensures cancel runs when the handler returns, releasing the timer resource.
// Forgetting cancel leaks the timer until it fires, wasting memory.
defer cancel()
data, err := callExternalAPI(ctx)
if err != nil {
// Return 504 Gateway Timeout to indicate the upstream service was too slow.
http.Error(w, "timeout", http.StatusGatewayTimeout)
return
}
w.Write(data)
}
The error check if err != nil { return err } is verbose by design. It forces you to acknowledge the failure path. Wrap the error with %w to preserve the chain for debugging.
A timeout that doesn't cancel goroutines is just a lie.
Chaining contexts
Contexts form a tree. You can derive a timeout from an existing context. If the parent cancels, the child cancels too. This lets you combine request cancellation with a local timeout.
Derive a child context: ctx, cancel := context.WithTimeout(parentCtx, 500*time.Millisecond).
The child inherits the parent's deadline. If the parent's deadline is sooner than the child's timeout, the child deadline adjusts automatically. If the parent cancels, the child cancels immediately. The cancel function only cancels the child's timeout, not the parent.
Chaining lets you layer constraints. A request might have a global 5-second deadline. A specific database call inside that request might have a 500-millisecond timeout. Both constraints apply. The context tree enforces the tightest deadline.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
Forgetting defer cancel() is a common mistake. The timer keeps running until it fires. In a high-throughput server, thousands of leaked timers consume memory and CPU. The compiler won't catch this. It's a runtime resource leak. Always pair WithTimeout with defer cancel().
If your function blocks on a channel or mutex without checking ctx.Done(), the timeout does nothing. The goroutine hangs forever. The context can only cancel code that cooperates. Use select to wait on blocking operations alongside ctx.Done().
Comparing errors with == breaks when errors are wrapped. The compiler allows err == context.DeadlineExceeded, but the comparison returns false if the error is wrapped. You get a silent failure where the timeout handler never triggers. Use errors.Is(err, context.DeadlineExceeded) to handle wrapped errors correctly.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If a goroutine reads from a channel, ensure something closes that channel or the context cancels the read.
The worst goroutine bug is the one that never logs.
When to use each pattern
Use context.WithTimeout when you need a hard deadline for a single operation.
Use context.WithDeadline when the deadline is an absolute time, like a scheduled job cutoff.
Use context.WithCancel when you need manual cancellation without a timer.
Use context.Background() only at the entry point of your program or request.
Use http.NewRequestWithContext when making HTTP calls to propagate the timeout to the network layer.
Use errors.Is(err, context.DeadlineExceeded) to check for timeouts safely across wrapped errors.
Pick the tool that matches the constraint. Timeouts are constraints.