One Object, Built Once per Request
One Object, Built Once per Request
A nested query might call forty resolvers. Each one needs the authenticated user, a database handle, and the request id for logs — and none of them can take those as explicit parameters, because the executor calls resolvers with exactly four arguments and you do not own the call site.
So the server builds one object per request and hands it to every resolver as the third argument:
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ request }) => {
const user = await authenticate(request.headers.authorization);
return {
currentUser: user, // who is making this request
dataSources: makeDataSources(dbPool), // repositories for this request
requestId: request.headers['x-request-id'] || randomUUID(),
loaders: makeLoaders(dbPool), // caches must not outlive the request
};
},
});
// any resolver, at any depth
deletePost: async (_p, { id }, ctx) => {
const post = await ctx.dataSources.posts.byId(id);
if (!post || post.authorId !== ctx.currentUser.id) throw new ForbiddenError();
await ctx.dataSources.posts.remove(id);
return { deletedId: id };
}
The mental model: context is request-scoped dependency injection. It is constructed before execution starts, shared by every resolver in that execution, and dropped with the request. Resolvers never build their own collaborators, so replacing the database with an in-memory fake in tests means swapping one factory instead of stubbing forty call sites.
Two things it is not. It is not a cross-request cache — that is a shared object you create deliberately outside the handler. And it is not a home for single-field values; those arrive from the enclosing resolver as parent, as covered in resolver fundamentals.
What Goes In, What Stays Out
What Goes In, What Stays Out
Context earns its place only when the boundary is deliberate.
Belongs in context:
| Entry | Why |
|---|---|
currentUser |
authentication ran once; every resolver can assume identity |
dataSources |
one pool and one set of repositories for the whole request |
loaders |
per-request batching caches — see request scoped loaders |
requestId / trace id |
a single identifier tying every resolver's logs together |
logger |
a child logger with the request id already attached |
| pubsub handle | the event bus subscriptions publish to |
Does not belong:
- Field-level values.
post,user,authorarrive asparent. Copying them into context creates two sources of truth for the same data. - Module-scope mutable state. Declaring
const context = { currentUser: null }at startup turns context into a process-wide global — the third chapter shows what that costs. - Business policy. Context carries capability: this is the user, this is the pool. Deciding whether that user may archive a specific order belongs in the resolver or a directive.
That last split keeps authentication and authorization in their natural homes. The context factory answers who is calling — token parsing, session lookup, a failure that rejects the request before execution — while resolvers and directives answer may this caller do this for one field. Moving the first out of resolver bodies is what makes role based field access and authorization directives enforceable in one place instead of scattered checks.
One rule to state out loud in code review: treat context as read-only after creation. A resolver that writes ctx.currentUser = someoneElse edits an object the rest of the operation is still reading, and every field resolved after that line silently inherits the change. Derive a local variable instead.
The upstream mechanics of resolving identity from a request are in request auth context.
The Shared Context Leak
The Shared Context Leak
The most expensive context bug looks like a small optimization:
// module scope — ONE object for the whole process
const context = { currentUser: null, dataSources: null };
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ request }) => {
context.currentUser = await authenticate(request.headers.authorization);
context.dataSources = makeDataSources(pool);
return context; // same object every time
},
});
Single-user testing passes. Under concurrency it leaks identities:
t0 request A: context.currentUser = alice
t1 request B: context.currentUser = bob // overwrites alice
t2 request A: resolver reads ctx.currentUser -> bob
Alice's request executes with Bob's identity: another user's data in the response, and an authorization bypass that reproduces only when two requests overlap. It shows up as "flaky cross-account reports" and never reproduces with one curl session.
The fix is a fresh object inside the handler:
context: async ({ request }) => ({
currentUser: await authenticate(request.headers.authorization),
dataSources: makeDataSources(pool),
requestId: request.headers['x-request-id'] || randomUUID(),
}),
Three checks that keep it fixed:
- No context object literal at module scope. If one exists outside the handler, every request shares it.
- Resolvers never assign to
ctx. With a shared object, one branch writingctx.currentUserpoisons the rest of the request — and, in the buggy version, other requests as well. - Loaders are constructed in the factory. A module-scope dataloader batches keys across different users' requests, which is both a leak and a correctness hazard (request scoped loaders).
When a deep library needs request state you do not want to thread through ten signatures, Node's AsyncLocalStorage carries it along the async call chain instead of living on a shared object.
Interview angle: "how does the authenticated user reach a resolver?" Answer the per-request factory, then immediately name the failure mode of building it too early. Interviewers who keep pushing on concurrency are checking whether you have run the server under parallel load, not just in GraphiQL.
Practice Problems
Quiz
1. When is the context object created relative to resolver execution?
2. Which value belongs in context?
3. What symptom reveals a context object created at module scope?
4. Where are authentication and authorization separated in this design?
Flashcards
Question
When context is built
Click to reveal answer
Answer
Once per request by the context factory, before execution; every resolver in that request receives the same object.
Question
Context vs parent vs info
Click to reveal answer
Answer
context = request-scoped deps (user, data sources, loaders); parent = value the enclosing field resolved; info = AST, path, schema metadata.
Question
Module-scope context bug
Click to reveal answer
Answer
A shared context object mutated per request leaks identities under concurrency — the last writer wins. Always create a fresh object inside the handler.
Question
Authentication vs authorization placement
Click to reveal answer
Answer
Factory authenticates once (who); resolvers/directives authorize per field (may they do this).
Question
Why loaders must be per request
Click to reveal answer
Answer
Their caches key on request data; sharing one across requests batches different users together and serves stale rows.
Revision Notes
Key Takeaways
- 1. Context is request-scoped dependency injection: built once, shared by all resolvers, discarded with the request
- 2. It carries capability (user, pool, loaders, request id) — not resolved field values or business policy
- 3. Creating context at module scope leaks identity between concurrent requests; create it inside the handler
- 4. Resolvers treat context as read-only, and loaders are constructed fresh per request
Interview Tips
- • Answer "how do you pass the current user to resolvers" with the factory, then volunteer the shared-object leak as the failure mode you design against
- • Be ready to say what you would never store in context and why — parent values, mutable counters, and process config are the three interviewers probe for
Cheat Sheet
Resolver Context Cheat Sheet
context: async ({ request }) => ({
currentUser: await authenticate(request.headers.authorization),
dataSources: makeDataSources(pool),
loaders: makeLoaders(pool), // fresh caches per request
requestId: request.headers['x-request-id'] || randomUUID(),
})
- Created once per request, before execution; read-only from resolvers
- In: currentUser, dataSources, loaders, requestId/logger, pubsub
- Out: parent values, per-field results, module-scope mutable state, business policy
- Factory authenticates (who); resolvers/directives authorize (may they)
- Bug signature:
const context = {...}at module scope → cross-request identity leak - Deep ambient state without threading params: AsyncLocalStorage