skip to content

A client SDK stores the context.Context from NewClient in a struct field. What breaks?

level: seniorimportance: should knowfreq 42%

answer

  1. one caller's patience, shared by all callers
  2. object lifetime versus request lifetime
  3. the call site can no longer see it
  4. constructed inside a handler, then cached
  5. http.Request is the exception that proves it

basics

~20 s

Every call then runs under the context that existed at construction, not the caller's. A stored request context poisons the client once that request ends; a stored empty root ignores every caller's deadline. Pass ctx per method instead.

solid answer

~50 s

The client freezes one caller's lifetime into a value shared by all callers. Two failure shapes follow. If `NewClient` was handed a request-scoped context, that context is cancelled when the request ends, and every later call fails instantly with `context.Canceled` — a bug that looks like an outage but is really construction order. If it was handed `context.Background()`, the opposite happens: callers' deadlines and cancellations never reach the wire, so a caller that gave up two seconds ago still has a request in flight. It also freezes request-scoped values, so one request's calls are labelled with another's identifiers. The fix is the convention: no context field, `ctx context.Context` first on every exported method. The narrow exceptions are types that *are* a request — `http.Request` carries a context and exposes it through `Context()` — and a daemon holding its own lifetime context.

code

go · 16 lines
go
type Client struct {
	ctx  context.Context // frozen at construction; every call inherits it
	http *http.Client
}

func NewClient(ctx context.Context) *Client {
	return &Client{ctx: ctx, http: http.DefaultClient}
}

func (c *Client) Get(path string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, path, nil)
	if err != nil {
		return nil, err
	}
	return c.http.Do(req)
}

go deeper

for a junior

Remember the rule and the reason in one line: a context belongs to one call, a field belongs to the object, so a stored context applies somebody else's deadline to everyone. Pass ctx as the first parameter of each method.

for a middle

Be able to walk both outcomes concretely: a stored request context poisons the client permanently once that request ends, while a stored empty root makes every caller's deadline unenforceable. Explain why the call site can no longer see that a context is involved.

for a senior

Diagnose it from symptoms — a dependency that appears hard-down until restart, or upstream calls outnumbering live requests — and describe a staged migration for a published API where adding ctx is a breaking change.

for a principal

Own it as a signature policy for surfaces other teams compile against: what your exported methods must take, which lifetime-context exceptions are allowed, and why this gets enforced in CI instead of trusted to review, given that the defect is invisible at the call site.

## The rule and the reason The `context` package documentation states it directly: do not store a `Context` inside a struct type; pass it explicitly to each function that needs it, as the first parameter, named `ctx`. It is one of the few style rules in Go with a hard behavioural justification rather than an aesthetic one. A context is **request-scoped by construction**. It represents one caller's patience: one deadline, one cancellation signal, one set of identifiers. A struct field, by contrast, is **object-scoped**: it lives as long as the value does and is shared by every caller of every method. Putting the first inside the second commits the object permanently to one caller's answer to "when should this stop?" ## The two failure shapes Consider an SDK other teams import. ```go type Client struct { ctx context.Context // frozen at construction http *http.Client } func NewClient(ctx context.Context) *Client { … } func (c *Client) Get(path string) (*http.Response, error) { /* uses c.ctx */ } ``` Notice what the method signature now advertises: `Get(path string)` looks like a call that cannot be cancelled and has no deadline. A reviewer reading the *call site* has no way to know a context is involved at all. That invisibility is half the problem. **Shape one: the frozen request.** A caller constructs the client inside an HTTP handler, passing `r.Context()`, and caches the client for reuse. The handler returns; `net/http` cancels the request context; the client is now permanently poisoned. Every later call returns `context.Canceled` before a byte leaves the process. The symptom is brutal to read from the outside — a dependency appears to go hard-down with no network activity, and it recovers only on restart, because the poisoning is inherited from whichever request happened to build the client. **Shape two: the deaf client.** The more common variant: the constructor is called from `main` with `context.Background()`. Now nothing is ever cancelled. A caller that wraps its call in a two-second deadline gets no benefit at all — the SDK ignores it, because the outbound request was built with the stored root. Under load this is how a service ends up with far more in-flight upstream calls than it has live requests: each caller gave up, and nothing downstream ever heard. **Shape three, quieter: stale values.** Any request-scoped data attached to the stored context — a request or correlation identifier — is now attached to every call the client ever makes. The tenth request's outbound calls carry the first request's identifiers, and correlated logs point at the wrong request. ## The fix Move the context to the method. The struct keeps only things that genuinely belong to the object's lifetime: a transport, a base URL, credentials, a limiter. ```go type Client struct{ http *http.Client } func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) ``` Now the signature tells the truth, the per-call budget is the caller's, and the client is genuinely safe to share across concurrent callers with different deadlines. For an SDK this matters more than in application code: a signature you export is a promise other teams build on, and adding `ctx` to a published method later is a breaking change for every one of them. Deciding this at v0 is cheap; deciding it after adoption is somebody's migration. ## The legitimate exceptions The rule is strong but not absolute, and knowing the exceptions is what separates a memorised rule from an understood one. - **A struct that *is* a request.** `http.Request` holds a context and exposes it via `Context()`, with `WithContext` producing a shallow copy carrying a new one. This is consistent, not contradictory: the struct's own scope *is* the request's scope, so the field cannot outlive what it describes. - **A long-lived component's own lifetime.** A daemon or worker loop may hold a context representing *its own* shutdown, derived at construction and cancelled by its `Close` method. It is not standing in for a caller's context; it is the object's lifetime. The tell is that no exported method uses it in place of a caller-supplied one — methods still take their own `ctx`, and the loop's own body watches the field. - **A struct being used purely as an argument bundle** for a single call, where the struct's lifetime is the call's. This is defensible but easy to get wrong later, when someone caches the bundle. ## Making the rule stick On a team this is worth mechanising rather than remembering. A lint rule in CI that (a) rejects a `context.Context` typed field on any struct outside a small allowlist and (b) requires `ctx context.Context` as the first parameter of exported functions catches both halves of the anti-pattern at the point where it is cheapest to fix — before a signature is published and other teams have compiled against it. Review alone will not hold the line, because the failure is invisible at the call site, which is exactly where reviewers spend their attention.

  • http.Request holds a context in a field. Why is that not a violation of the same rule?
    Because the struct's scope and the context's scope are the same thing. An `http.Request` *is* one request, so a context that lives exactly as long as it cannot outlive what it describes. The API also keeps it honest: `Context()` reads it and `WithContext` returns a shallow copy rather than mutating, so no request ever silently changes budgets underneath a handler.
  • Is it ever right for a long-lived worker to hold a context.Context in a field?
    Yes, when the field is the worker's *own* lifetime — a context created at construction and cancelled by `Close` or `Stop`, which its internal loop watches to know when to exit. The test is whether any exported method uses that field in place of a caller-supplied context. If methods still take their own `ctx` first, the field is a shutdown switch, not a stand-in for a caller.
  • You inherit an SDK with this defect and it has external consumers. How do you migrate it?
    Adding `ctx` to existing methods is a breaking signature change, so stage it: add the context-taking methods alongside the current ones, make the old ones delegate with the stored context so behaviour is unchanged, move consumers over, then delete the old surface and the field. Do the frozen-request case first — a client built from a request context is a live outage waiting for the wrong construction order, while the deaf-client case is only a missed optimisation.
  • How would you stop this pattern from reappearing across a large codebase?
    Mechanise both halves in CI: reject `context.Context`-typed struct fields outside a reviewed allowlist, and require `ctx context.Context` as the first parameter of exported functions. Review will not hold the line here because the damage is invisible at the call site — the signature looks like an ordinary uncancellable call, so there is nothing for a reviewer's eye to catch.

It is like stamping one customer's closing time onto the shop door. Either the shop shuts for everyone the moment that customer leaves, or the stamp says 'never closes' and nobody else's schedule is ever honoured.

saying these in an interview costs you the question

  • Calls it merely a style preference with no behavioural cost
  • Thinks the stored context refreshes itself on each call
  • Cannot explain why http.Request is different
  • Suggests storing a context and replacing the field per call
  • Believes a cancelled stored context affects only in-flight calls