The Shared-Loader Data Leak
The Shared-Loader Data Leak
The comfortable version of a loader looks like this:
// loaders.js — imported everywhere, one instance per process
export const userLoader = new DataLoader(async (ids) => {
const rows = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
...
});
It works in development, passes every test written by one person, and is wrong the moment two requests overlap. The memo map lives as long as the process, so it outlives the request that populated it:
- Tenant acme sends a request; its resolver loads user 42, whose row includes email and phone. The row lands in the shared cache.
- Tenant globex sends a request moments later; its resolver guesses id 42. The memo returns the acme row before the batch query ever runs — no SQL, no tenant filter, no chance for a check that would have rejected it.
- The user deletes their account; the stale row keeps serving until the process restarts.
| Instance scope | Cache lifetime | Failure mode |
|---|---|---|
| Module (global) | process | cross-request and cross-tenant reads, unbounded growth |
| Per request | one request | duplicate queries between requests — the safe direction |
Blast radius is wider than one bad read: any authorization performed inside the batch query is bypassed on a cache hit, because a hit never executes the query. And the memory side is quieter — a process-wide map keyed by every id the API has ever served grows for as long as the process lives.
The fix is structural, not a bigger cache: loader lifetime must equal request lifetime.
Why this survives code review: locally, one developer sends one request at a time, so the shared map behaves exactly like a per-request one. In tests, a single fixture set means the cache is always warm with the right data. The bug needs two overlapping requests and data that differs between them — concurrency plus per-caller scope — which is precisely production traffic. The memory half shows up later and more quietly: a process-wide map keyed by every id any client has ever loaded grows until restart, and because entries are promises holding rows, the heap holds resolved values too.
The pattern is not unique to loaders. Anything created at module scope whose contents depend on who asked — pagination cursors, rate-limit buckets keyed by user, per-request tracing spans — has the same lifetime mismatch. The test is always the same: who else can see what this object has stored?
Building Loaders Inside the Context Factory
Building Loaders Inside the Context Factory
Context is built once per request, before execution starts, and every resolver receives it. That makes it the only place a loader should be born:
function createLoaders(db, viewer) {
return {
user: new DataLoader(async (ids) => {
const rows = await db.query(
'SELECT * FROM users WHERE id = ANY($1) AND tenant_id = $2',
[ids, viewer.tenantId] // scope comes from the verified caller
);
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null);
}),
post: new DataLoader(...),
};
}
const server = new ApolloServer({
schema,
context: async ({ req }) => {
const viewer = await verifyToken(req.headers.authorization);
return { viewer, loaders: createLoaders(db, viewer) };
},
});
// resolvers read from context, never from an import
Post: {
author: (post, _args, ctx) => ctx.loaders.user.load(post.authorId),
},
Three things this buys:
- Isolation. Each request starts with empty maps, so nothing observed by one caller can be returned to another. The tenant filter inside the batch query now actually runs on every fetch.
- One construction point. Callers, loaders, and data sources are wired together in a single function that is trivial to unit test: build a context with a fake viewer, assert the loader scope.
- Explicit lifetime. When the response finishes, the maps become garbage. Nothing has to be cleared because nothing is shared.
The alternative — clearing a global cache between requests — is racy by construction: a clear that runs after the next request has begun drops entries mid-flight, a clear that runs early still leaves entries written by an overlapping request, and the ordering depends on middleware you do not control. Fresh instances have no shared state to coordinate.
Loaders built at import time or lazily cached on globalThis are the same bug wearing a different coat. For how the keys and batch windows behave once the instance exists, see DataLoader configuration.
Lifetime Edges: Subscriptions, Tests, and In-Flight Batches
Lifetime Edges: Subscriptions, Tests, and In-Flight Batches
A request ends when its response is sent. Some work does not.
WebSocket connections. Headers arrive once, at the HTTP upgrade, and connection_init carries the token for the whole socket. If the connection context builds loaders once, those maps serve every event on a socket that may stay open for hours — the global-loader bug at a smaller radius, still scoped to one authenticated user but stale for as long as the connection lives. Two sound approaches:
// Per-operation loaders: fresh maps for every subscription event
context: async (ctx, msg, connection) => ({
viewer: connection.viewer,
loaders: createLoaders(db, connection.viewer),
}),
or build once and clearAll() at the start of each event. Fresh instances are simpler to reason about; clearing is acceptable only when you control exactly where the boundary falls. Connection lifetime rules to state explicitly: token verified at connect, loaders scoped per event, and a plan for a token that expires mid-stream — disconnect or re-verify rather than trusting an hour-old claim. See subscription lifecycle for the state machine around this.
Tests. A shared loader across test cases makes results order-dependent: the first test to load id 7 decides what the second test sees, and a fixture that mutates row 7 between cases silently asserts on stale data. Fresh context per test is the fix, and a test that fails only when run in a certain order is usually this bug.
In-flight batches. Between two requests there can be a batch still awaiting the database. With per-request instances the two never interact — request A's dispatch completes against A's maps. With a shared instance, clearAll() cannot cancel that dispatch; it can only remove entries already written, so a slow query from A can populate the map after your clear ran.
Design rule worth memorizing: state that is safe for one request must be created per request, not cleaned between requests. The same reasoning applies to anything else you are tempted to put in module scope — tracing spans, rate counters keyed by user, per-operation pagination cursors.
Practice Problems
Quiz
1. Where should DataLoader instances for a GraphQL server be created?
2. Why is clearing a shared loader between requests weaker than creating new instances?
3. A WebSocket connection stays open for an hour. Why do its loaders need special handling?
Flashcards
Question
Why a global loader leaks
Click to reveal answer
Answer
Its memo map outlives the request, so a row cached for one caller is returned to the next — cache hits bypass the batch query and its tenant filter.
Question
Correct loader lifetime
Click to reveal answer
Answer
Equal to the request: created in the context factory, discarded when the response is sent.
Question
Why clearing is inferior to recreating
Click to reveal answer
Answer
Clears race with overlapping requests and in-flight batch dispatches; new instances share nothing to race over.
Question
Subscription loader rule
Click to reveal answer
Answer
Scope loaders per operation/event on a long-lived socket, and define what happens when the token expires mid-connection.
Question
Smell of shared loader state
Click to reveal answer
Answer
Tests that pass alone and fail in a suite, or values that appear correct for one user and stale for another.
Revision Notes
Key Takeaways
- 1. A process-scoped memo map turns a cache hit into an authorization bypass, because the filtered query never runs
- 2. Create loaders in the per-request context factory; tenant, viewer, and loaders are built together
- 3. Anything whose lifetime exceeds one request — sockets, tests, in-flight batches — needs an explicit scoping decision
Interview Tips
- • Describe the concrete leak as a two-request sequence with ids and tenants; a story beats an abstraction
- • Be ready to answer where loaders live for subscriptions — that question separates people who have shipped realtime from people who have not
Cheat Sheet
Request-Scoped Loader Checklist
Rule: loader lifetime === request lifetime
context: async ({ req }) => {
const viewer = await verifyToken(req.headers.authorization);
return { viewer, loaders: createLoaders(db, viewer) };
}
Never: import a loader from module scope · stash one on globalThis · clear a shared map between requests
Why a cache hit is a security bug: hits skip the batch SQL, so tenant and permission filters never run
Subscriptions: verify token at connect → derive connection context → build loaders per event (or clear at the event boundary)
Tests: fresh context per case; order-dependent failures usually mean shared loader state
Migration: grep for new DataLoader outside the context factory — every hit is a candidate leak