Skip to content
advanced Phase 9 · Solving the N+1 Problem with DataLoader

Request-scoped Loader Instances

Creating loaders per request so cached data never leaks between concurrent users.

40m
1 problems
Topic Progress 0%

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:

  1. Tenant acme sends a request; its resolver loads user 42, whose row includes email and phone. The row lands in the shared cache.
  2. 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.
  3. 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

0 / 1 solved
Find and Fix the Cross-Request Leak
Request Scope Diagnosis

A server exports a single userLoader at module scope; its batch query filters by tenant taken from a module variable set by middleware. Walk a two-request sequence and explain what each caller observes, then rewrite the wiring so loader state cannot cross a request boundary.

Example:

Input: Request A (tenant acme) loads user 42 at 10:00:00.000. Request B (tenant globex) loads user 42 at 10:00:00.010. user 42 belongs to acme.

Output: B receives acme's row for user 42 (cache hit), or a wrong-tenant row if the module tenant variable has already been overwritten

The memo key is the raw id, and a hit returns before the batch query runs, so B skips the tenant filter entirely. If the miss path does run, the module-level tenant variable may now hold globex — either way the scope is decided by global mutable state rather than by the caller.

Optimal Solution — O(N) time, O(N) per request space

Move loader construction into the per-request context factory, closing over the verified viewer so the tenant filter and the cache are both scoped to the caller

// before: module scope, shared by every request
export const userLoader = new DataLoader(async (ids) => {
  const rows = await db.query(
    'SELECT * FROM users WHERE id = ANY($1) AND tenant_id = $2',
    [ids, currentTenant]            // mutable module variable
  );
  ...
});

// after: built per request, closed over the verified viewer
function createLoaders(db, viewer) {
  const userLoader = new DataLoader(async (ids) => {
    const rows = await db.query(
      'SELECT * FROM users WHERE id = ANY($1) AND tenant_id = $2',
      [ids, viewer.tenantId]        // fixed for the request lifetime
    );
    const byId = new Map(rows.map((r) => [r.id, r]));
    return ids.map((id) => byId.get(id) ?? null);
  });
  return { user: userLoader };
}

const context = async ({ req }) => {
  const viewer = await verifyToken(req.headers.authorization);
  return { viewer, loaders: createLoaders(db, viewer) };
};

Post: {
  author: (post, _args, ctx) => ctx.loaders.user.load(post.authorId),
},

Edge Cases:

  • Anonymous request: build loaders with a null viewer and a batch query that returns nothing
  • Token refresh mid-request: the viewer captured at context build stays fixed for that request
  • Concurrent requests from the same tenant: separate maps mean a duplicate query, which is the correct trade

Quiz

1. Where should DataLoader instances for a GraphQL server be created?

Question 1 options

2. Why is clearing a shared loader between requests weaker than creating new instances?

Question 2 options

3. A WebSocket connection stays open for an hour. Why do its loaders need special handling?

Question 3 options

Flashcards

Question

Why a global loader leaks

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

Answer

Equal to the request: created in the context factory, discarded when the response is sent.

Question

Why clearing is inferior to recreating

Answer

Clears race with overlapping requests and in-flight batch dispatches; new instances share nothing to race over.

Question

Subscription loader rule

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

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

Continue Learning