
Context in Go: passing data, deadline, cancel - use correctly or die of performance
- Golang
- August 15, 2025
Table of Contents
When I first coded in Go, I encountered a strange production bug: after running for 3-4 days, the service suddenly consumed 8GB of RAM and caused an OOM error. I spent the entire Friday afternoon debugging and finally discovered the issue: a goroutine never ended because I forgot to pass the context to the gRPC call. Each request leaked a goroutine, and after 100K requests, the server was overwhelmed.
That was the first time I understood that context.Context is not just a few lines of code for show. Using it incorrectly can be disastrous. Using it correctly ensures that requests are handled cleanly, goroutines are properly managed, and the system runs smoothly.
Below are the lessons I learned after a few “deaths” due to context issues - all with runnable Go code.

Context does 3 things right - don’t turn it into a trash bag
Before diving into code, it’s essential to understand what context is for:
- Cancellation - notifies goroutine to stop when a request is cancelled
- Deadline/Timeout - sets a time limit for a task
- Passing lightweight metadata - request ID, user ID, trace ID
Not: a storage for heavy objects, not a dependency injection container. I’ve seen codebases that stuff DB connections into context. Sad.
A mantra to self-check: “Is this value truly an execution context, or just a business parameter?” If it’s a business parameter → pass it as a function parameter. Don’t stuff it into context.
Passing Data Correctly with context.Value
package ctxkeys
type key string
const (
RequestID key = "request_id"
UserID key = "user_id"
Locale key = "locale"
)
Using typed keys to avoid collisions - an empty string "" is a recipe for the hardest-to-debug bug I’ve ever encountered.
ctx = context.WithValue(ctx, ctxkeys.RequestID, reqID)
// Extract with type assertion - always check ok
if id, ok := ctx.Value(ctxkeys.UserID).(string); ok {
log.Printf("[%s Processing request", id)
}
3 principles I strictly adhere to:
- ✅ Small metadata (< 1KB): request_id, user_id, trace_id, locale
- ❌ Heavy objects: DB pool, Redis client, config struct → pass through constructor
- ❌ Business data: order ID, product filter, query params → pass through function params
- ❌ Reading
ctx.Valuein hot loops → extract into a local variable once, then loop
I’ve seen code that retrieves ctx.Value within a loop of 10K items. The CPU profile showed that 15% of the time was spent on type assertion of ctx.Value. Extracting it outside the loop made the 15% disappear.
Deadline and Timeout
// Timeout for DB query
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT * FROM orders WHERE user_id = $1", userID)
The defer cancel() is more important than you think. Not calling cancel leads to a context leak, where the timer resource is not released until the timeout expires. In a service that receives 1K requests per second, with each leak lasting 3 seconds… you can do the math.
Deadline from upstream should be propagated, not overridden:
func (s *Service) GetOrder(ctx context.Context, orderID string) (*Order, error) {
// DON'T do this:
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// DO this - respect deadline from caller:
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.repo.GetOrder(ctx, orderID)
}
If the client has set a deadline of 2s, wrapping it with WithTimeout(ctx, 5s) will still result in a deadline of 2s (the shorter one is taken). However, if you use context.Background() as the parent, you will break the chain and invalidate the client’s deadline. This error cost me 2 hours of debugging.
Cancel when the result is no longer needed
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-ctx.Done():
return // cleanup, no leak
case result := <-ch:
process(result)
}
}()
Passing context through layers
// gRPC handler
func (s *Server) GetMenu(ctx context.Context, req *pb.MenuRequest) (*pb.MenuResponse, error) {
// ctx is passed through
merchant, err := s.merchantRepo.GetByID(ctx, req.MerchantId)
if err != nil {
return nil, err
}
items, err := s.menuService.ListItems(ctx, merchant.ID, req.Filters)
if err != nil {
return nil, err
}
return &pb.MenuResponse{Items: items}, nil
}
Errgroup: Running Concurrently, Canceling Collectively
import "golang.org/x/sync/errgroup"
func fetchDashboard(ctx context.Context, userID string) (*Dashboard, error) {
g, ctx := errgroup.WithContext(ctx)
var users *UserList
var orders *OrderList
var stats *Stats
g.Go(func() error {
var err error
users, err = fetchUsers(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = fetchOrders(ctx, userID)
return err
})
g.Go(func() error {
var err error
stats, err = fetchStats(ctx, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return &Dashboard{users, orders, stats}, nil
}
One goroutine errors → ctx cancel → 2 remaining goroutines stop early. Clean.
Worker pool + context
func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
log.Printf("worker %d: shutting down", id)
return
case job, ok := <-jobs:
if !ok {
return
}
results <- process(job)
}
}
}
Trace: always extract from context
func HandleRequest(ctx context.Context, req *Request) (*Response, error) {
span, ctx := opentracing.StartSpanFromContext(ctx, "HandleRequest")
defer span.Finish()
// Pass ctx down - trace ID is automatically propagated
return s.service.Process(ctx, req)
}
No need to manually extract the trace ID and put it in the log - the context does that automatically if you set up instrumentation correctly.
Test with context
func TestGetOrder_WithTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := svc.GetOrder(ctx, "slow-order-123")
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
Cheatsheet
| Task | Use | Do not use |
|---|---|---|
| Request-scoped metadata | ✅ ctx.Value | ❌ Business data |
| Deadline/Timeout | ✅ context.WithTimeout | ❌ context.Background override |
| Cancel goroutine | ✅ ctx.Done() + select | ❌ Forget defer cancel() |
| Pass through layers | ✅ Function param | ❌ Global variable |
Bottom line
Context in Go is not difficult. The difficulty lies in discipline: always passing context down, always calling defer cancel(), not putting garbage into context.Value, and not overriding deadlines from upstream. I had to encounter several production bugs to remember these points. Hopefully, after reading this, you won’t have to pay the price like I did.
Have you ever debugged a “goroutine leak due to forgetting to cancel context” bug before? Or does your team have any conventions regarding context.Value? I’m curious about how other teams handle this - tell me about it. 🦞