skip to content

What belongs in a GraphQL resolver's request context, and why must it be built per request?

level: middleimportance: must knowfreq 66%

answer

  1. Ask what the value is scoped to
  2. One object per request, threaded unchanged
  3. Principal, tenant, correlation id, deadline, loaders
  4. Shared caches outlive the viewer they filtered for
  5. Nothing in the specification defines it

basics

~20 s

The context carries state belonging to one request: the authenticated principal, tenant, locale, a correlation id, a deadline and that request's batch loaders. Building it fresh per request is what keeps one caller's authorized data out of another caller's response.

solid answer

~50 s

The context is a convention rather than a specified construct: the transport layer builds one object per request and the executor threads it, unchanged, into every resolver, so a resolver never reaches for global state. Put in it whatever is derived from this request and must be identical for every field -- the authenticated principal and its scopes, tenant, locale, a correlation id, a remaining-time budget, and this request's batch loaders. Keep out of it process-wide collaborators such as connection pools, HTTP clients or the executable schema, the raw transport request object, and mutable scratch space a later field expects to read back. The lifecycle is the point: a loader or cache created once at startup and shared by every request keys on ids alone, and will eventually serve a viewer a row that was authorized for somebody else.

code

pseudocode · 20 lines
pseudocode
# BUG: one loader for the whole process; its cache key omits the viewer
applicationsByJob = BatchLoader(fetch = jobIds -> {
    principal = CurrentPrincipal.get()             # mutable per-request global
    return db.applicationsVisibleTo(principal, jobIds)
})

function buildContext(request):
    return { loaders: { applicationsByJob: applicationsByJob } }   # shared!

# FIX: the loader is created inside the request's context and dies with it
function buildContext(request):
    principal = authenticate(request)
    return {
        principal: principal,
        correlationId: request.header("x-correlation-id"),
        loaders: {
            applicationsByJob: BatchLoader(fetch = jobIds ->
                db.applicationsVisibleTo(principal, jobIds))
        }
    }

go deeper

for a junior

Know that the current user does not come from a global: it is derived once per request and handed to resolvers through the context. Be able to list a few things that legitimately live there.

for a middle

Explain the lifecycle -- who constructs the context, that the executor passes the same instance to every resolver, and why per-request batch loaders and per-request caches must not be shared between requests.

for a senior

Diagnose the failure mode: a cache whose key omits the viewer but whose contents were authorization-filtered. Show how per-request construction removes the window, and what a shared cache must store instead.

for a principal

Set the platform rule: what the context guarantees, who may add to it, and how authorization-filtered data is allowed to be cached at all. This is where a cross-tenant leak either becomes structurally impossible or stays one careless loader away.

## A convention with one hard rule Nothing in GraphQL's specification mentions a context. The specification hands the executor a schema, a document, an operation name, variable values and an initial value, and it says a field's resolver is called with the parent value and the coerced arguments. The context is what every mainstream server added on top so that a resolver could learn something about *this request* without reaching for global state. The convention is uniform and worth stating precisely: the transport layer builds **one context object per request**, and the executor threads that same object, unchanged, into every resolver invocation for that request. The context is therefore the only sanctioned channel for request-scoped state, and its lifetime is the request's lifetime. That lifetime is not a detail; it is the whole safety property. ## What belongs in it Anything derived from this request that more than one field needs, and that must be identical for every field: - the **authenticated principal** and its scopes, roles or tenant -- resolved once, so two fields cannot disagree about who is asking; - **locale, timezone, currency preference** and other per-caller presentation inputs; - a **correlation id** and a **deadline** or remaining-time budget; - **this request's batch loaders**, whose dedup cache is meant to live exactly as long as the request does; - read-only, request-derived flags: an experiment assignment, a feature-flag evaluation snapshot. ## What does not belong in it - **Process-wide collaborators.** A connection pool, an HTTP client, the executable schema, a metrics registry: these have no per-request identity, so wire them once and inject them, or place them behind the loaders you *do* build per request. - **The raw transport request object.** Putting it in invites resolvers to read headers and cookies ad hoc, couples domain code to the transport, and hides what a resolver actually depends on. Extract the two or three derived values instead. - **Mutable scratch space that later fields read back.** Sibling fields of one selection set may be resolved in any order, so a resolver that writes a value another resolver expects to find has created an ordering dependency the executor never promised. (The concurrency hazards of shared per-request state are their own subject; the signature-level rule is simply that the context is a read-mostly carrier.) ## The incident: a cache that served another viewer's row A job-board graph exposes `Job.applications`, and application rows are visible only to recruiters at the hiring company. Applications were fetched through a batch loader, and the loader was created **once, at process start**, then handed to every request's context. Its fetch function was authorization-aware: it read the current principal from a mutable per-request global and asked the datastore for the rows visible to that principal. The fetch was right. The **cache** was wrong, because its key was the job id and nothing else. A recruiter opened job 5187. The loader memoised 23 application rows under key `5187`. Ninety seconds later a candidate requested the same job, the loader returned a cache hit without calling the fetch function at all, and the candidate read 23 competitors' names. No authorization check was skipped and no resolver was buggy: the viewer was implicit in the loader's construction while the cache key was not, and the shared lifetime turned that mismatch into a data leak. It took 41 minutes to notice, and every response in that window looked completely normal. ## Why per-request construction is the fix Build the loader inside the context factory, and the viewer becomes implicit in the object's *lifetime* rather than in a global read: the loader closes over the principal that request authenticated with, and its cache is destroyed with the request, so there is no window in which a second viewer can hit it. The key does not need to carry the viewer because the cache cannot outlive one viewer. If a genuinely shared, process-wide cache is warranted for cost reasons, the rule becomes: cache only authorization-independent data -- the job row, the company profile -- and apply visibility filtering **after** the cache read, on the per-request principal. Never memoise a result that was already filtered for a specific viewer under a key that omits that viewer. ## Long-lived transports A subscription served over a WebSocket complicates the "per request" phrasing. Conventionally the context is built once when the connection is initialised, from the initialisation payload, and reused for every operation on that socket. That makes credential expiry a real concern: a socket may outlive the token that authorised it, so servers either re-check authorization per delivered event or bound the connection's lifetime. ## Specified versus conventional Specified: nothing. Not the context's existence, its name, its contents or its lifetime. Conventional but near-universal: one context per request, built by the transport, passed unchanged to every resolver, holding principal and per-request loaders.

  • How do you decide whether a value belongs on the context or should be read inside each resolver that needs it?
    Two tests. If many fields need it and they must all agree on it -- the principal, the deadline, today's date for the request -- derive it once at context construction. If it is field-specific or cheap and independent, read it in the resolver. Anything expensive that only one rare field uses belongs behind a lazily-invoked collaborator, not eagerly on the context.
  • The same graph is served over HTTP and over a long-lived WebSocket subscription. Where does the context come from then?
    Conventionally it is built once when the connection is initialised, from the initialisation payload, and reused for every operation on that socket. The hazard is lifetime: the socket can outlive the credential that authorised it, so servers either re-check authorization as events are delivered or cap the connection's lifetime.
  • What is wrong with putting the raw HTTP request object on the context?
    It lets any resolver read headers and cookies ad hoc, so a resolver's real dependencies become invisible and domain code is coupled to the transport -- the same resolver can no longer serve a subscription or an internal caller. Extract the two or three derived values you actually need, and keep the transport at the edge.

Think of the context as a wristband issued at the door: everyone inside this one visit shows the same band, and it is cut off on the way out. A band reused by tomorrow's visitor is exactly the bug.

saying these in an interview costs you the question

  • Creates batch loaders once at startup and shares them
  • Reaches for a static or thread-local instead of the context
  • Puts connection pools and clients on the context
  • Caches viewer-filtered rows under a key without the viewer
  • Uses the context as a scratchpad later fields read back
  • Assumes one context can safely span a whole WebSocket

context