When the client leaves but the work stays
A user clicks "Generate Report" on your dashboard. The server starts a heavy computation in a goroutine and returns a 200 OK so the UI can show a loading spinner. The user gets impatient, closes the tab, and walks away. The goroutine is still crunching numbers. It holds a database connection. It allocates memory for the result. It runs until the job finishes, wasting resources for a user who no longer cares.
This is the request-scoped lifetime problem. In Go, goroutines live until they return. The HTTP handler returning does not stop the goroutine. You must wire the goroutine's lifecycle to the request's lifecycle. The mechanism is context.Context. The context carries a cancellation signal from the request handler to the background worker. When the request ends, the context cancels, and the worker stops.
The cancellation contract
Go does not kill goroutines. It asks them to stop. Cancellation is cooperative. The runtime provides the signal; your code must check for it.
Think of the context as a walkie-talkie channel shared between the request handler and the goroutine. The handler is the base station. The goroutine is the field agent. The base station can transmit an "abort" signal at any time. The agent must keep the radio on and listen. If the agent ignores the radio, the abort signal is useless.
The context.Context interface is the radio. It exposes a Done() channel. When the context is cancelled, the Done() channel closes. A select statement on ctx.Done() unblocks immediately. This pattern lets any goroutine react to cancellation without polling or shared state.
Convention aside: context.Context is always the first parameter of a function, conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. This convention makes it easy to trace the signal through the call stack.
Minimal request-scoped handler
Here's the simplest pattern: derive a cancellable context from the request, spawn a goroutine, and select on the context.
func handler(w http.ResponseWriter, r *http.Request) {
// Derive a cancellable context from the request.
// The request context carries deadlines and cancellation signals from the server.
ctx, cancel := context.WithCancel(r.Context())
// Ensure the cancellation signal fires even if the handler returns early.
defer cancel()
// Start background work in a new goroutine.
go func() {
// Block until work finishes or context is cancelled.
select {
case <-ctx.Done():
// Request ended. Clean up and exit immediately.
return
case result := <-doWork(ctx):
// Work completed. Process the result.
_ = result
}
}()
// Respond immediately to keep the client happy.
w.WriteHeader(http.StatusOK)
}
The defer cancel() is the safety net. When the handler returns, cancel() runs. This closes the ctx.Done() channel. The goroutine sees the close and exits. Without defer cancel(), the goroutine might run forever if the work never completes.
Context is plumbing. Run it through every long-lived call site.
Walkthrough: signals and cleanup
When the request arrives, the HTTP server creates a context attached to r. This context is cancelled when the client disconnects or the server shuts down. Calling context.WithCancel(r.Context()) creates a child context. The child inherits the parent's cancellation. If the parent cancels, the child cancels automatically.
The goroutine starts and hits the select. It blocks on two channels: ctx.Done() and the result channel from doWork. The select picks a case when one of the channels is ready. If the client closes the tab, the server cancels r.Context(). The cancellation propagates to ctx. The ctx.Done() case fires. The goroutine returns.
If doWork finishes first, it sends on the result channel. The second case fires. The goroutine handles the result and returns.
The handler writes the response and returns. The defer cancel() executes. This is redundant if the goroutine already exited, but necessary if the goroutine is still running. The cancel() call ensures the goroutine stops. It also releases resources held by the context.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. When checking ctx.Err(), return the error directly. Do not wrap it unless you add meaningful context.
Realistic worker with periodic checks
The minimal example assumes doWork respects cancellation. If doWork blocks on a long operation without checking the context, the goroutine leaks. Real workloads need periodic checks.
// doWork simulates a long-running task that respects cancellation.
// It returns a channel that delivers the result or an error.
func doWork(ctx context.Context) <-chan string {
// Buffered channel prevents sender blocking if receiver exits.
ch := make(chan string, 1)
go func() {
// Close channel on exit to signal completion to any receiver.
defer close(ch)
// Simulate work that can be interrupted.
for i := 0; i < 10; i++ {
// Check context before each step to avoid wasting time.
select {
case <-ctx.Done():
return
default:
}
// ... work ...
}
ch <- "done"
}()
return ch
}
The worker checks ctx.Done() in a loop. The select with default is non-blocking. It checks if the context is done. If so, it returns. If not, it continues to the next iteration. This pattern works for loops, but not for blocking calls like network I/O.
For blocking calls, pass ctx to the function. Functions like http.NewRequestWithContext accept a context. If the context cancels, the request aborts. The function returns ctx.Err().
Convention aside: _ discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. Dropping an error silently is a bug waiting to happen.
Pitfalls and leaks
Goroutine leaks are the silent killers of Go services. A leak happens when a goroutine waits on a channel that never gets closed. The goroutine stays in memory. The garbage collector cannot reclaim it. Over time, the service runs out of memory.
The most common leak in request-scoped code is forgetting to close the result channel. If doWork returns a channel and the goroutine exits without closing it, the receiver blocks forever. Always defer close(ch) in the sender.
Another leak is ignoring cancellation. If the worker calls time.Sleep or a blocking I/O without a context, it ignores the abort signal. The handler returns, cancel() fires, but the worker keeps sleeping. The goroutine leaks.
The compiler rejects this with an undefined-variable error if you reference ctx without declaring it. Forget to import a package and you get undefined: pkg. Forget to use one and you get imported and not used. The compiler catches syntax errors, but it cannot catch logic leaks. You must test cancellation paths.
Convention aside: Public names start with a capital letter. Private start lowercase. No keywords like public or private. The Context type is public. The cancel function is lowercase because it's a local variable, not a method.
The worst goroutine bug is the one that never logs.
Decision: cancellation strategies
Use context.WithCancel when you need explicit control over cancellation from multiple callers. Use context.WithTimeout when the operation must finish within a fixed duration, regardless of client behavior. Use context.WithDeadline when you have a specific wall-clock time by which the work must complete. Use r.Context() directly when the background work is short-lived and you don't need to cancel it independently of the request. Use a dedicated done channel when you need to signal completion to multiple listeners beyond simple cancellation.
Goroutines are cheap. Leaks are expensive.