Context in Go: passing data, deadline, cancel - use correctly or die of performance

Context in Go: passing data, deadline, cancel - use correctly or die of performance

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 in Go

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:

  1. Cancellation - notifies goroutine to stop when a request is cancelled
  2. Deadline/Timeout - sets a time limit for a task
  3. 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.Value in 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

TaskUseDo not use
Request-scoped metadatactx.Value❌ Business data
Deadline/Timeoutcontext.WithTimeoutcontext.Background override
Cancel goroutinectx.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. 🦞

Share :