April 9, 2026 10 min read

Go Context Propagation in Payment Pipelines — Why context.Background() Is Costing You Money

I spent three weeks tracking down why our payment service was leaking database connections under load. The root cause? A single context.Background() call buried in a helper function that bypassed every timeout and cancellation signal we had in place.

The Problem with context.Background() in Payment Code

If you've worked on payment systems in Go, you've probably seen this pattern everywhere:

// DON'T do this in payment pipelines
func ChargeCard(amount int64, cardToken string) (*ChargeResult, error) {
    ctx := context.Background() // ← This is the problem
    row := db.QueryRowContext(ctx, "SELECT * FROM merchants WHERE token = $1", cardToken)
    // ...
    resp, err := paymentGateway.Charge(ctx, &ChargeRequest{Amount: amount})
    // ...
}

On the surface it looks harmless. The function needs a context, so you create one. But in a payment pipeline, this single line disconnects your database query and gateway call from every safety mechanism upstream — timeouts, cancellation, tracing, and request-scoped metadata all vanish.

When a client disconnects mid-checkout, the HTTP handler's context gets cancelled. But your ChargeCard function doesn't know that. It's running on a fresh context.Background() with no deadline. The gateway call hangs for its full 30-second default timeout. Multiply that by a few hundred concurrent requests during a traffic spike, and you've got a connection pool that's completely exhausted.

How Context Flows Through a Payment Pipeline

A well-structured payment pipeline passes context from the entry point all the way down to the last I/O call. Here's what that flow looks like in practice:

HTTP Request
ctx + timeout + traceID
Validate
ctx propagated
Fraud Check
ctx propagated
Charge
ctx propagated
Ledger Write
ctx deadline enforced

Each stage inherits the parent context — timeouts, trace IDs, and cancellation signals propagate end-to-end.

Every stage receives the same parent context. If the HTTP request is cancelled at any point, every downstream operation — the fraud check, the gateway charge, the ledger write — gets the cancellation signal immediately. No orphaned work, no wasted connections.

The Right Way: Accept Context, Don't Create It

The fix is straightforward. Every function in your payment pipeline should accept a context.Context as its first parameter and pass it through:

func ChargeCard(ctx context.Context, amount int64, cardToken string) (*ChargeResult, error) {
    // Add a stage-specific timeout on top of the parent deadline
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    row := db.QueryRowContext(ctx, "SELECT * FROM merchants WHERE token = $1", cardToken)
    // ...
    resp, err := paymentGateway.Charge(ctx, &ChargeRequest{Amount: amount})
    // ...
}

Now the function respects the caller's deadline. If the parent context has 8 seconds remaining but we set a 5-second timeout here, the effective deadline is 5 seconds. If the parent only has 2 seconds left, the effective deadline is 2 seconds. context.WithTimeout always picks the sooner deadline.

Request-Scoped Values: Trace IDs and Merchant Context

Context isn't just about timeouts. In payment systems, you need request-scoped metadata flowing through every layer — trace IDs for distributed tracing, merchant IDs for multi-tenant isolation, and idempotency keys for safe retries.

type ctxKey string

const (
    traceIDKey    ctxKey = "trace_id"
    merchantIDKey ctxKey = "merchant_id"
    idempotencyKey ctxKey = "idempotency_key"
)

// Middleware injects request-scoped values
func PaymentMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        ctx = context.WithValue(ctx, traceIDKey, r.Header.Get("X-Trace-ID"))
        ctx = context.WithValue(ctx, merchantIDKey, r.Header.Get("X-Merchant-ID"))
        ctx = context.WithValue(ctx, idempotencyKey, r.Header.Get("Idempotency-Key"))
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Deep in the pipeline, values are still available
func recordLedgerEntry(ctx context.Context, entry *LedgerEntry) error {
    traceID, _ := ctx.Value(traceIDKey).(string)
    merchantID, _ := ctx.Value(merchantIDKey).(string)

    log.Info("recording ledger entry",
        "trace_id", traceID,
        "merchant_id", merchantID,
        "amount", entry.Amount,
    )
    return db.ExecContext(ctx, "INSERT INTO ledger ...")
}

When someone uses context.Background() mid-pipeline, all of this metadata disappears. Your logs lose correlation. Your traces break. And when an incident happens at 2 AM, you're flying blind.

Tip: Use custom unexported types for context keys (like ctxKey above) instead of plain strings. This prevents collisions between packages that might use the same key name. It's a small thing, but in a payment system with dozens of internal packages, it prevents subtle bugs that are painful to track down.

Context-Aware Database Queries

Every database call in a payment service should use the context-aware variants: QueryRowContext, ExecContext, QueryContext. But there's a subtlety that trips people up — what happens when the context is cancelled between the query and reading the result?

func getPaymentStatus(ctx context.Context, paymentID string) (string, error) {
    // The context controls both the query execution AND the connection checkout
    // from the pool. If ctx is cancelled while waiting for a free connection,
    // the call returns immediately instead of blocking.
    var status string
    err := db.QueryRowContext(ctx,
        "SELECT status FROM payments WHERE id = $1", paymentID,
    ).Scan(&status)

    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            // Log and return a specific error — the caller needs to know
            // this wasn't a DB failure, it was a timeout
            return "", fmt.Errorf("payment status query timed out: %w", err)
        }
        return "", err
    }
    return status, nil
}

The key insight: db.QueryRowContext uses the context for connection pool checkout, not just query execution. Under load, when your pool is saturated, a proper context with a deadline prevents goroutines from piling up waiting for connections. With context.Background(), those goroutines wait indefinitely.

context.Background() vs Proper Propagation

Scenario context.Background() Propagated Context
Client disconnects Work continues, connections held All downstream ops cancelled
Gateway timeout Waits full default timeout (30s+) Respects parent deadline
Distributed tracing Trace chain broken, new root span Full trace from HTTP to DB
DB pool under load Goroutines pile up indefinitely Goroutines exit on deadline
Incident debugging No request correlation in logs Full request context in every log line
Cost impact Wasted compute, held connections, potential double-charges Clean resource release, predictable behavior

When context.Background() Is Actually Correct

I'm not saying you should never use context.Background(). There are legitimate cases:

The rule is simple: if there's a parent context available, use it. Only reach for context.Background() when you're genuinely starting a new, independent operation.

Warning: If you find yourself writing context.Background() inside an HTTP handler or any function called from one, stop and ask why. Nine times out of ten, it means someone forgot to thread the context through, and the right fix is to add ctx context.Context to the function signature — not to paper over it with a fresh context.

Catching context.Background() in Code Review

We added a simple linting rule to our CI pipeline that flags any use of context.Background() or context.TODO() outside of main(), init(), or test files. It's not perfect — it catches some legitimate uses — but it's started good conversations in code review and caught real bugs before they hit production.

You can also use go vet analyzers or tools like contextcheck from the golangci-lint suite to catch functions that accept a context but don't pass it to downstream calls.

A Practical Checklist

  1. Every exported function that does I/O should accept context.Context as its first parameter.
  2. Use context.WithTimeout to add stage-specific deadlines — don't rely solely on the parent timeout.
  3. Always call defer cancel() immediately after creating a derived context.
  4. Use context-aware database methods (QueryRowContext, ExecContext) everywhere.
  5. Check ctx.Err() in error handling to distinguish timeouts from actual failures.
  6. Inject trace IDs and merchant IDs via context values at the middleware layer.

Getting context propagation right isn't glamorous work. But in payment systems, where a leaked goroutine can mean a double-charge and a connection pool exhaustion can mean a full outage during peak traffic, it's some of the highest-leverage code you'll write.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.