Kamau Wanyee

Batching GraphQL field resolvers with one microtask

·7 min read
#posts#graphql#nodejs#performance#event-loop

Batching GraphQL field resolvers with one microtask

Twenty sibling GraphQL fields call load() one after another in the same JavaScript turn. Dispatch immediately and you get twenty downstream requests. Schedule one microtask and you get one deduplicated IN (...) query.

The N+1 shape

The simplest way to write a GraphQL field that references a related entity: resolve the parent, fetch the child. When a list returns twenty items and each has a related assignee, that’s twenty calls to the data layer.

It’s hard to fix because each field resolver is isolated. The resolver for item.assignee knows its own userId but nothing about the nineteen others asking for different users in the same execution. The spec offers no grouping mechanism.

DataLoader is the standard answer. It works well when entities come from a single database or uniform API. It gets harder when a generic field routes to multiple upstream services based on a source discriminator: task IDs to one service, referral IDs to another. With DataLoader, that means one instance per entity type. A generic loader that routes internally puts it in one place.

The event loop gives you a window

JavaScript processes tasks one at a time. GraphQL’s field resolution for a list happens in one such task; the engine doesn’t yield between sibling resolvers.

Every load() call across a resolver wave lands in the same synchronous turn. No I/O has started. Every ID the wave will need is already in memory.

Microtasks run after the stack clears, before any I/O or timers. queueMicrotask puts work at that boundary. One boolean flag keeps it from firing more than once per wave.

flowchart LR
  S[Current resolver call stack] --> L1[load 1]
  S --> L2[load 2]
  S --> L3[load 3]
  L1 --> M[Microtask queue]
  L2 --> M
  L3 --> M
  M --> D[Single dispatch]
  D --> I[One IN query per source]

Four pieces of state

The loader is created fresh for each GraphQL request and passed through context. A module-level loader would bleed state across concurrent requests.

cache: Map<string, unknown> keyed by ${source}:${id}. Once a key resolves, every subsequent load() for that key returns from cache without touching pending.

pending: Map<string, PendingEntry>, where each entry holds the source name, the entity ID, and an array of { resolve, reject } callbacks. When two resolvers ask for the same source:id, they append to the same waiter array. One downstream call, two resolved promises.

scheduled: a boolean. The first load() call that misses cache sets this to true and calls queueMicrotask(dispatch). Every subsequent call in the same turn appends to pending and returns.

proxyFor: a function from source name to upstream client. All the routing lives here. Dispatch only needs to know that any client returned by proxyFor supports list({ id: { in: ids } }).

Dispatch

When the microtask fires, dispatch replaces pending with a fresh empty map and resets scheduled.

async function dispatch() {
  scheduled = false;
  const batch = pending;
  pending = new Map();

  const bySource = new Map<string, Set<string>>();
  for (const [, entry] of batch) {
    const ids = bySource.get(entry.source) ?? new Set<string>();
    ids.add(entry.id);
    bySource.set(entry.source, ids);
  }

  for (const [source, ids] of bySource) {
    try {
      const rows = await proxyFor(source).list({ id: { in: [...ids] } });
      resolveWaiters(source, rows, batch);
    } catch (err) {
      rejectWaiters(source, err, batch);
    }
  }
}

From the snapshot, it groups IDs by source, deduplicates with a Set, and sends one bulk query per source. Each source awaits independently, so a failure from the task service rejects task waiters without affecting referral waiters.

Fanning out

function resolveWaiters(
  source: string,
  rows: Row[],
  batch: Map<string, PendingEntry>
) {
  const byId = new Map(rows.map((r) => [r.id, r]));

  for (const [key, entry] of batch) {
    if (entry.source !== source) continue;
    const value = byId.get(entry.id) ?? null;
    cache.set(key, value);
    for (const waiter of entry.waiters) {
      waiter.resolve(value);
    }
  }
}

A missing row resolves to null. Resolvers that need to treat absence as an error can check the return value. Rejecting the promise would break callers that expect optional entities.

sequenceDiagram
  participant G as GraphQL execution
  participant L as Request loader
  participant DB as Downstream service

  G->>L: load(task, 1)
  Note over L: schedule one microtask
  G->>L: load(task, 2)
  G->>L: load(task, 2)
  G->>L: load(referral, 9)
  Note over L: current JS stack ends
  L->>DB: task IDs IN [1, 2]
  L->>DB: referral IDs IN [9]
  DB-->>L: rows
  L-->>G: resolve four promises

The swap that protects the batch

The pending = new Map() at the top of dispatch is the reason later loads don’t corrupt the in-flight batch. Without it:

dispatch starts iterating pending (source: task, ids: [1, 2])
→ await proxy.list(task IDs)
  ← load(task, 3) arrives during the await
  ← it appends to pending
  ← scheduled is still true, so it skips queueMicrotask
← proxy.list() resolves
→ dispatch finishes the task loop
→ id 3 is in pending, but dispatch already processed task
→ waiter for id 3 never resolves

By replacing pending before the first await, any load arriving during I/O lands in the new map, sees scheduled as false, and queues a fresh microtask.

flowchart TB
  P[Pending map A] --> Snap[Snapshot A · replace with empty map]
  Snap --> W[Await downstream query]
  W --> R[Resolve waiters A]
  NewLoad[New load arrives during await] --> PB[Pending map B]
  PB --> MB[queueMicrotask → dispatch B]

The same pattern appears in buffered writers, actor mailboxes, and event emitter draining loops: snapshot and replace before you process.

Why not DataLoader?

DataLoader has been solving this problem in production GraphQL servers for years. If your backend is a single database or uniform API, it’s less code with a well-understood contract.

A custom loader makes sense when requests route to multiple upstream services by source, query shapes differ per service, or grouping requires a composite key DataLoader doesn’t model. A single generic loader with internal routing is simpler than one DataLoader per entity type.

A custom loader also owns the snapshot swap, per-source rejection semantics, null caching, and query-shape conflict handling. That’s more to test and more to explain. Use it when DataLoader’s model doesn’t fit.

Caveats

One query shape per source per wave. Two resolvers calling load("task", id) with different selection shapes means the last one wins. Either validate and throw on conflict, merge shapes at registration time, or document that all callers must agree. Silent field drops surface late.

Synchrony is assumed. A resolver that awaits something before calling load() arrives in a different turn and forms a separate batch.

Source failures are all-or-nothing. If the task service throws, every task waiter in the current wave rejects. The loader doesn’t retry individual IDs.

The cache is request-local. It eliminates duplicate loads within one GraphQL execution. Cross-request deduplication needs a layer above it.

Tests this optimization deserves

  • Two load() calls for the same source:id in the same synchronous turn produce exactly one upstream query
  • Calls for two different sources in the same turn produce exactly two upstream queries, one per source
  • Two calls for the same source with different query shapes either throw or are reconciled without silently dropping one
  • A load() for an ID absent from the upstream response resolves to null
  • A load() arriving during dispatch’s await forms its own batch; both batches eventually resolve all waiters
  • A source-level failure rejects only that source’s waiters; other sources in the same dispatch resolve normally
  • A load() for a cached key returns immediately without touching pending or calling queueMicrotask

The grouping, deduplication, and fan-out all have analogues in DataLoader. The swap does not. Getting it wrong means a promise that never resolves.