The zombie goroutine problem
You build an API endpoint that fetches user data. Most requests finish in 50 milliseconds. One day, the database network blips. A query hangs. The HTTP handler blocks waiting for the result. The client waits. The server goroutine sits there, holding memory, doing nothing. Five minutes later, the database recovers, the query returns, and the response goes to a client that already gave up. Meanwhile, your server has a thousand of these zombie goroutines. The memory usage climbs. The connection pool exhausts. The server crashes.
You need a way to say "stop waiting" and clean up. You need a timeout that actually cancels the work, not just notifies you that time has passed.
Timer versus deadline
time.After and context.WithTimeout both deal with time, but they solve different problems. time.After gives you a channel that receives a value after a duration. It is a one-shot alarm. context.WithTimeout creates a context that carries a deadline. When the deadline passes, the context cancels. You can pass that context to other functions. They can check the context. If they respect the context, they stop.
time.After is a notification. context is a command.
Think of time.After like a kitchen timer. It rings. You hear it. You might ignore it. The cooking continues. context.WithTimeout is like a manager walking in and pulling the plug. Everyone stops. The oven turns off. The food is discarded. The kitchen is safe.
In Go, context.Context is the standard way to propagate cancellation. It is an interface. It has a Done method that returns a channel. The channel closes when the context cancels. It has an Err method that returns the reason. It has a Deadline method that returns the time. Functions that take a context should check ctx.Done() and return early if it closes.
Context cancellation is cooperative. The context does not kill the goroutine. It just closes a channel. The code must check the channel. If you don't check, the context does nothing. This is a common mistake. You set a timeout, but the function ignores the context. The function runs forever. The timeout is useless. Always check the context.
Minimal example
Here is the simplest way to combine a timer and a context. The context has a shorter deadline than the timer. The context wins.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// WithTimeout creates a context that cancels after 2 seconds.
// cancel releases resources associated with the context.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
// time.After sends a value after 5 seconds.
// This channel is buffered, so the timer fires even if nobody reads.
case <-time.After(5 * time.Second):
fmt.Println("Timer fired")
// ctx.Done closes when the context cancels or deadline passes.
// Reading from a closed channel returns immediately with zero value.
case <-ctx.Done():
fmt.Println("Stopped:", ctx.Err())
}
}
# output:
Stopped: context deadline exceeded
The program prints "Stopped: context deadline exceeded". The context cancels after 2 seconds. The select sees ctx.Done() close. It picks that case. The timer never fires from the perspective of the code. The timer fires in the background, sends to its channel, and the channel is buffered, so it doesn't block. The timer is garbage collected eventually.
The defer cancel() is critical. context.WithTimeout allocates resources. If you don't call cancel, those resources stay alive until the context is garbage collected. In a tight loop, forgetting cancel leaks memory. Always call cancel. The defer is your friend.
Convention aside: context.Context always goes as the first parameter. The variable is usually named ctx. Functions that take a context should respect cancellation and deadlines. This is a universal rule in Go. If you see a function that takes a context but ignores it, it is broken.
How the context tree works
context.Background() creates the root context. It never cancels. context.WithTimeout creates a derived context. It is a child of the parent. When the parent cancels, the child cancels. When the timeout passes, the child cancels. When you call cancel, the child cancels.
This creates a tree. The root is at the top. Children hang below. Cancellation flows down. If the root cancels, everything cancels. If a child cancels, its children cancel. This structure lets you manage lifetimes cleanly.
In an HTTP server, http.Request has a Context() method. The server creates a context for each request. When the client disconnects, the server cancels the context. If you pass r.Context() to your handlers, they stop when the client leaves. This prevents zombie goroutines. If you use context.Background() instead, the handler runs even after the client disconnects. You waste resources. Always use r.Context() in handlers.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// DoWork simulates a task that checks for cancellation periodically.
// It accepts context as the first parameter by convention.
func DoWork(ctx context.Context) error {
for i := 0; i < 100; i++ {
select {
// Simulate work that takes time.
case <-time.After(100 * time.Millisecond):
fmt.Printf("Step %d done\n", i)
// Check if context cancelled during work.
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
// Handler sets a timeout for the request and passes it down.
func Handler(w http.ResponseWriter, r *http.Request) {
// Derive timeout from request context.
// The server cancels r.Context() if the client disconnects.
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
err := DoWork(ctx)
if err != nil {
http.Error(w, "Operation cancelled", http.StatusGatewayTimeout)
return
}
fmt.Fprintln(w, "Success")
}
The handler derives a context with a 500ms timeout from r.Context(). It passes the context to DoWork. DoWork checks ctx.Done() in a loop. If the timeout passes, DoWork returns context.DeadlineExceeded. The handler catches the error and returns a 504 status. The client gets a response. The goroutine cleans up.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't hide errors. Check them. Return them. The caller decides what to do.
Pitfalls and leaks
time.After is convenient, but it has traps. time.After creates a timer and a channel. If you call time.After in a loop and don't read the channel, you generate garbage. The timer fires, sends to the channel, the channel is buffered, nobody reads, the timer is done. The channel and timer are allocated. If the loop runs fast, you allocate faster than the garbage collector can collect. The memory usage climbs. The GC runs more often. The program slows down.
Use time.NewTimer in loops. time.NewTimer returns a timer you can stop. You can reuse the timer. You can call Stop to cancel it. This prevents leaks.
// BAD: time.After in a loop leaks resources under pressure.
// Each iteration allocates a timer and channel.
// If the loop runs fast, GC cannot keep up.
func BadLoop() {
for {
select {
case <-time.After(1 * time.Second):
fmt.Println("Tick")
}
}
}
// GOOD: NewTimer allows stopping and reusing the timer.
func GoodLoop() {
timer := time.NewTimer(1 * time.Second)
defer timer.Stop()
for {
select {
case <-timer.C:
fmt.Println("Tick")
// Reset for next iteration.
timer.Reset(1 * time.Second)
}
}
}
context.WithTimeout panics if you pass a negative duration. The runtime checks the duration. If it is less than or equal to zero, it panics with negative time argument. Always validate durations before creating contexts. If you calculate a timeout, ensure it is positive.
// This panics at runtime.
ctx, _ := context.WithTimeout(context.Background(), -1*time.Second)
// panic: negative time argument
Context is not a bag for passing data. You can use context.WithValue to attach values, but this is a last resort. It breaks the type system. It makes code hard to trace. Use explicit parameters instead. Context is for cancellation and deadlines. Stick to that.
Convention aside: gofmt is mandatory. Don't argue about indentation. Let the tool decide. Most editors run it on save. Your code should look like everyone else's code. Consistency matters more than style.
Decision matrix
Use time.After when you need a simple one-shot timeout in a select and don't need to propagate cancellation to other functions.
Use context.WithTimeout when the operation spans multiple function calls and you need to enforce a deadline across the call stack.
Use time.NewTimer when you are inside a loop or need to stop the timer early to prevent resource leaks.
Use context.WithCancel when you need manual cancellation control without a fixed deadline, such as stopping a background worker on user request.
Use select with multiple channels when you are waiting for one of several events, including timeouts, results, or cancellation signals.
Context is plumbing. Run it through every long-lived call site.