When the timeout doesn't save you
You write an HTTP handler. It calls a downstream service. The service decides to hang. Your server starts holding goroutines open. Memory creeps up. You check the code and see you added a timeout. You called context.WithTimeout. So why is the goroutine still running?
The bug isn't the timeout. The bug is how you passed the context. You might have derived the timeout from a nil context, or from a parent that was already cancelled, or you forgot to call the cancel function. The timeout exists, but the signal never reaches the work, or the work never starts.
Context mistakes are subtle because the compiler rarely stops you. Go allows you to pass a context that looks valid but behaves brokenly. The errors show up at runtime as hangs, leaks, or panics. Fixing them requires understanding how context propagates and what the context actually does.
Context is a signal tree
Context in Go is a signal carrier. It travels down the call stack telling functions when to stop. It is not a bag for data. It is not a configuration object. It is a cancellation and deadline signal.
Think of context like a fuse in an electrical circuit. The fuse carries the current, but its job is to blow when things go wrong and cut the power. If you disconnect the fuse from the circuit, the power keeps flowing even when the fuse blows. If you hand someone a fuse that is already blown, the device never turns on.
Contexts form a tree. A parent context can have multiple children. When a parent is cancelled, all children are cancelled immediately. Children do not cancel parents. If a child times out, the parent keeps running. This directionality matters. You derive children from parents, never the other way around.
The context.Context interface has four methods. Done returns a channel that closes when the context is cancelled. Err returns the reason for cancellation. Deadline returns the time by which the context will be cancelled. Value retrieves a key-value pair, though using values is discouraged for anything other than request-scoped metadata.
package main
import (
"context"
"fmt"
"time"
)
// doWork simulates a task that checks for cancellation.
func doWork(ctx context.Context) error {
select {
case <-time.After(2 * time.Second):
// Work completed successfully.
return nil
case <-ctx.Done():
// Context was cancelled or timed out.
// Return the error so the caller knows why.
return ctx.Err()
}
}
func main() {
// Create a root context.
ctx := context.Background()
// Derive a timeout context from the root.
// The timeout is 1 second, shorter than the work duration.
derivedCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel() // Always call cancel to release resources.
err := doWork(derivedCtx)
if err != nil {
fmt.Println("Work failed:", err)
}
}
The defer cancel() call is mandatory. The cancel function stops the timer and closes the Done channel. Without it, the timer goroutine keeps running until the timeout expires, even if you are done early. This is a common source of goroutine leaks.
Context is plumbing. Run it through every long-lived call site.
The nil context trap
Passing nil as a context is a frequent mistake. The compiler allows it because nil is a valid interface value. The runtime does not stop you until something tries to use the context.
If you pass nil to a function that calls ctx.Done(), the program panics. The interface value is nil, so there is no method to call. The runtime crashes the goroutine immediately.
package main
import (
"context"
"fmt"
)
// riskyCall expects a valid context.
func riskyCall(ctx context.Context) error {
// This line panics if ctx is nil.
// The compiler cannot detect this at build time.
<-ctx.Done()
return nil
}
func main() {
// Mistake: Passing nil context.
// The caller assumes the function handles nil, but it does not.
err := riskyCall(nil)
fmt.Println(err)
}
The panic message is panic: runtime error: invalid memory address or nil pointer dereference. This error gives no hint that the context was nil. You have to look at the stack trace to find the source.
Some third-party libraries check for nil and treat it as "no cancellation". This is inconsistent behavior. The standard library does not check for nil. If you pass nil to context.WithTimeout, the function panics with panic: context: WithTimeout on nil context. The error is explicit here, but only if you hit the derivation step. If you pass nil directly to a worker, you get the cryptic nil pointer dereference.
Never pass nil. Use context.Background() at the root of your call tree. context.Background() returns a non-nil context that is never cancelled. It is the safe starting point.
Deriving from a cancelled parent
Another mistake is deriving a new context from a parent that is already cancelled. This happens when you reuse a context across multiple operations, or when you derive a timeout after the request has been aborted.
When you call context.WithTimeout on a cancelled parent, the child context is born cancelled. The Done channel is already closed. ctx.Err() returns context.Canceled immediately. Your function exits before doing any work.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// Create a context and cancel it immediately.
parentCtx, parentCancel := context.WithCancel(context.Background())
parentCancel() // Parent is now cancelled.
// Mistake: Deriving a timeout from a cancelled parent.
// The child context is cancelled instantly.
childCtx, childCancel := context.WithTimeout(parentCtx, 5*time.Second)
defer childCancel()
// This check fails immediately.
if childCtx.Err() != nil {
fmt.Println("Child is already cancelled:", childCtx.Err())
}
}
This pattern breaks retries and fallbacks. If you cancel a context after the first attempt, and then try to derive a new timeout for the retry, the retry fails instantly. You must derive the timeout from a valid parent, or use a fresh context for independent operations.
Check the parent before deriving. If the parent is cancelled, handle the error and return. Do not try to create a child that will never run.
The silent leak: forgetting cancel
The cancel function releases resources associated with the context. For context.WithTimeout, it stops the timer goroutine. For context.WithCancel, it closes the Done channel. If you forget to call cancel, the resources stay allocated.
The leak is silent. The program continues to run. The timer goroutine stays alive until the timeout expires. If the timeout is long, or if you create many contexts without cancelling them, you accumulate goroutines. Eventually, the server runs out of memory or file descriptors.
package main
import (
"context"
"fmt"
"time"
)
// leakyHandler creates a context but forgets to cancel it.
func leakyHandler() {
// Mistake: No defer cancel().
// The timer goroutine runs for 10 seconds even if we return early.
ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
// Simulate early return.
if true {
return
}
// ctx is unused, but the timer is still ticking.
_ = ctx
}
func main() {
for i := 0; i < 1000; i++ {
leakyHandler()
}
// 1000 timer goroutines are now running.
// They will run for 10 seconds before dying.
time.Sleep(1 * time.Second)
fmt.Println("Leaked goroutines are still active.")
}
The compiler does not warn about missing cancel calls. The linter staticcheck might catch it, but only if you configure it correctly. The safest pattern is to call defer cancel() immediately after deriving the context. This ensures the cancel runs when the function returns, regardless of the exit path.
If you need to cancel early, call cancel() explicitly and then return. The defer will run again, but calling cancel multiple times is safe. It is a no-op after the first call.
Realistic example: HTTP handler with database
In a web server, the request context is the root for that request. The http.Request has a Context() method that returns a context cancelled when the client disconnects. You should derive all timeouts and cancellations from this context.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// queryDB simulates a database query that respects context.
func queryDB(ctx context.Context) (string, error) {
select {
case <-time.After(2 * time.Second):
// Query completed.
return "result", nil
case <-ctx.Done():
// Context cancelled or timed out.
return "", ctx.Err()
}
}
// handleData processes a request with a database timeout.
func handleData(w http.ResponseWriter, r *http.Request) {
// r.Context() is the request context.
// It is cancelled when the client disconnects.
parentCtx := r.Context()
// Derive a timeout for the database call.
// The timeout is shorter than the default request timeout.
ctx, cancel := context.WithTimeout(parentCtx, 500*time.Millisecond)
defer cancel() // Release the timer when the handler returns.
result, err := queryDB(ctx)
if err != nil {
// Check if the error is due to context cancellation.
if ctx.Err() == context.DeadlineExceeded {
http.Error(w, "Database query timed out", http.StatusGatewayTimeout)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write([]byte(result))
}
func main() {
http.HandleFunc("/data", handleData)
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", nil)
}
This pattern ties the database call to the request lifecycle. If the client disconnects, r.Context() is cancelled. The derived timeout context is also cancelled. The database query sees the cancellation and returns early. The server releases the goroutine and connection immediately.
If you used context.Background() instead of r.Context(), the database call would ignore client disconnection. The goroutine would run until the 500ms timeout, even if the client left instantly. This wastes resources and delays cleanup.
Context always goes as the first parameter. The convention name is ctx. Functions that take a context must respect cancellation. If you ignore ctx.Done(), you break the contract. The caller expects the function to stop when the context is cancelled.
Pitfalls and compiler errors
Storing context in a struct is a bad idea. Contexts are transient. They represent the scope of a single call. Structs are often long-lived. If you store a context in a struct, you risk holding onto a cancelled context, or leaking the timer goroutine.
package main
import (
"context"
"time"
)
// Worker holds a context. This is an anti-pattern.
type Worker struct {
ctx context.Context
}
func NewWorker() *Worker {
// Mistake: Creating a context for the worker.
// The worker outlives the context scope.
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Hour)
// cancel is lost. The timer leaks.
_ = cancel
return &Worker{ctx: ctx}
}
func (w *Worker) DoWork() error {
// The context might be cancelled or timed out.
// The worker state is now unreliable.
return w.ctx.Err()
}
Pass context as a parameter to methods. Do not embed it in the struct. If a method needs a timeout, derive it from the passed context. This keeps the context scope explicit and tied to the call.
The compiler rejects unused imports with imported and not used: "context". This error helps you avoid dead code. If you import context but do not use it, remove the import. The compiler also rejects undefined variables with undefined: ctx. These errors are helpful, but they do not catch logical mistakes like passing the wrong context.
The worst goroutine bug is the one that never logs. Context leaks and hangs often produce no logs. The goroutine just sits there, waiting for a signal that never comes. Use defer cancel() and check ctx.Err() to catch these issues early.
Decision matrix
Use context.Background() when you are at the root of a call tree and have no other context to derive from. Use context.WithTimeout when a downstream operation might hang and you need a hard deadline to release resources. Use context.WithCancel when you need to signal cancellation from multiple places or based on external events. Use r.Context() in HTTP handlers to tie your work to the request lifecycle. Use a plain function call without context when the operation is fast, local, and cannot be cancelled.
Derive, don't discard. Always pass context down the call stack.