Context is Go’s answer to cancellation, deadlines, and request-scoped state. Used right, your services shut down cleanly, slow operations get killed, and goroutines don’t leak. Used wrong, you have phantom goroutines and zombie requests. This post is the working set.
The mental model
A context.Context is a tree:
Request comes in → ctx with timeout 30s
↓ derive ctx for DB call → timeout 5s
↓ derive ctx for cache lookup → timeout 100ms
↓ derive ctx for LLM call → timeout 20s
Cancel the parent → all children cancel. Set a tighter deadline on a child → the child times out first.
The basic forms
ctx, cancel := context.WithCancel(parent)
defer cancel()
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
ctx, cancel := context.WithDeadline(parent, time.Now().Add(time.Hour))
defer cancel()
ctx := context.WithValue(parent, RequestIDKey, id)
Every WithCancel / WithTimeout / WithDeadline returns a cancel func. Always defer cancel. Failing to do so leaks resources.
HTTP server: every request has a context
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // cancelled when client disconnects
rows, err := db.QueryContext(ctx, "SELECT ... ")
if err != nil { ... }
}
Client disconnects → ctx cancelled → DB query gets killed. No wasted compute.
errgroup for concurrent calls
import "golang.org/x/sync/errgroup"
func handler(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
var user User
var orders []Order
g.Go(func() error {
return db.QueryRowContext(ctx, ...).Scan(&user)
})
g.Go(func() error {
rs, err := db.QueryContext(ctx, ...)
if err != nil { return err }
for rs.Next() { ... }
return rs.Err()
})
if err := g.Wait(); err != nil {
return err
}
// both queries succeeded
return nil
}
If one fails: ctx cancels; the other gets the cancel signal and returns. Total time = max of the two, not sum. See Go Concurrency .
Timeout per dependency
Different layers warrant different timeouts:
func handler(ctx context.Context) error {
// DB: tight
dbCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
user, err := loadUser(dbCtx)
if err != nil { return err }
// LLM: looser
llmCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
summary, err := llm.Summarize(llmCtx, user.Bio)
if err != nil { return err }
return nil
}
Each derived context inherits the outer deadline. If the parent is 1s left, the child can’t extend it — WithTimeout only tightens.
Request-scoped values
type ctxKey int
const RequestIDKey ctxKey = iota
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
// Later:
id, _ := ctx.Value(RequestIDKey).(string)
Use sparingly — only for cross-cutting values like request ID, user ID, trace span. Don’t pass business data through context; pass it as arguments.
Cancellation in goroutines
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok { return }
process(ctx, j)
}
}
}
Always select on <-ctx.Done() in long-lived goroutines. Otherwise: leak.
Detecting cancel during work
For CPU-bound loops:
for i, item := range items {
if i%1000 == 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
process(item)
}
Check periodically; not on every iteration (would be too costly).
Shutdown
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
log.Println("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
}
SIGTERM → ctx cancels → server shuts down with a 30s grace period. See Production Go Service .
Common mistakes
1. Not calling cancel
ctx, _ := context.WithTimeout(...) // BAD: discarded cancel
Resources leak until timer fires. Always:
ctx, cancel := context.WithTimeout(...)
defer cancel()
2. Storing context in a struct
type Service struct {
ctx context.Context // BAD
}
Context is per-call, not per-instance. Pass it as the first argument always.
3. context.Background() in hot paths
Always derive from the request’s context. Otherwise: cancellation doesn’t propagate.
4. Context cancel after sleep
time.Sleep(5 * time.Second) // BAD: doesn't honor cancel
Use:
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
5. Using context.WithValue for everything
Context is for cross-cutting concerns (trace, request ID, user). Not for arguments.
Real-world example
func (h *Handler) HandleEvent(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error { return h.persist(gCtx, event) })
g.Go(func() error { return h.publish(gCtx, event) })
g.Go(func() error { return h.audit(gCtx, event) })
if err := g.Wait(); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(204)
}
Three concurrent calls; first error cancels rest; 30s ceiling.
Read this next
- Go Concurrency: Goroutines & Channels
- Go REST API with net/http
- Go 1.24 Features in 2026
- Go Web Frameworks: Gin, Echo, Chi
If you want my Go service template (context-aware, errgroup, graceful shutdown), it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .