skip to content

Why does awaiting an unrelated call before a DataLoader load split one batch into many?

level: middleimportance: should knowfreq 54%

answer

  1. The batch leaves without waiting for you
  2. Only queued keys ride in that dispatch
  3. Your resolver resumes in a later round
  4. One-key batches are the signature
  5. Queue the key before you yield

basics

~20 s

Only keys already queued when the dispatch pass runs ride in that batch. The await lets execution reach its stall point, the batch goes out without your key, and your resolver's later load starts a new, smaller batch.

solid answer

~50 s

A loader dispatches when execution can make no further progress, and it carries whatever keys are queued at that instant. A resolver that runs straight through to `load` has contributed its key. A resolver that awaits a network call first has not: the await yields, the engine drains everything else, dispatch fires with your key missing, and your resolver resumes in a later round to queue into a fresh batch. Do that inside a per-item field over a list of 43 and you get up to 43 one-key batches instead of one batch of 43 — the same N+1 you installed the loader to remove. The signature in metrics is batch-function invocations climbing towards the number of keys loaded, with a pile of one-key batches. Fix it by hoisting per-request pre-work into context, or by issuing the `load` first and awaiting afterwards.

code

pseudocode · 12 lines
pseudocode
// splits: the key is queued after dispatch has already run
resolve Locker.currentCourier(locker, ctx):
    policy = await ctx.policyService.fetchFor(locker.hubId)   // network call, resumes later
    if not policy.showsCourier: return null
    return ctx.courierLoader.load(locker.courierId)

// keeps one batch: queue first, yield afterwards
resolve Locker.currentCourier(locker, ctx):
    pendingCourier = ctx.courierLoader.load(locker.courierId) // queued in this pass
    policy = await ctx.policyService.fetchFor(locker.hubId)
    if not policy.showsCourier: return null
    return await pendingCourier

go deeper

for a junior

Know that a loader sends the keys it has when the moment comes, and that waiting on something else inside a resolver means your key misses that send. Be able to say the batch left without you.

for a middle

Walk the sequence out loud — resolver yields, execution stalls, batch dispatches without the key, resolver resumes and queues into a new batch — and name at least two fixes, hoisting the pre-work and issuing the load before awaiting.

for a senior

Demonstrate that you would prove it with numbers rather than by reading code: invocations against keys loaded, the keys-per-batch distribution, and per-request counters. Then distinguish the accidental extra round from the dependency-driven one that is supposed to be there.

for a principal

Frame it as a design constraint the schema and resolver conventions must encode: where per-request context is assembled, what a field resolver is allowed to do before loading, and how a loader-round budget is measured so this regression is caught by the pipeline rather than by a customer.

## One rule explains the whole behaviour **Only the keys already queued when the dispatch pass runs ride in that batch.** Everything about awaits and split batches follows from that single sentence, so it is worth saying out loud before reasoning about any specific case. A loader's dispatch fires when execution can make no further progress. A resolver that runs straight through to `load` and parks has contributed its key before that moment. A resolver that awaits something else first has not: the await yields, the engine drains the rest of its runnable work, reaches the stall, and dispatches whatever is in the queue. Your resolver resumes afterwards, in a later round, and its `load` call queues a key into a fresh, emptier batch. Nothing is broken. The loader did exactly what it promised, on the keys it had. ## The locker-board arithmetic A parcel-locker graph, with a `Locker` type carrying 37 fields, 12 of them loader-backed. A board document asks for 43 lockers and all 12 of those fields, so 516 field resolutions each call `load` once. Resolved cleanly that is 12 batch-function calls of up to 43 keys. Now suppose one of those 12 fields — the current courier — grows a per-locker visibility check: ```pseudocode resolve Locker.currentCourier(locker, ctx): policy = await ctx.policyService.fetchFor(locker.hubId) // network call, resumes later if not policy.showsCourier: return null return ctx.courierLoader.load(locker.courierId) ``` Every one of the 43 courier resolutions now yields at the policy call. Dispatch fires while all 43 are still suspended, so the courier loader dispatches with **zero** keys, and each of the 43 resolvers later resumes at whatever moment its own policy call returned. The engine stalls again, dispatches again, and again — in the worst case **43 batches of one key**, plus 43 policy calls, on top of the other 11 loaders that were unaffected. Do that to several fields and the tidy 12 round trips become hundreds. ## How to see it, rather than guess Total latency will not tell you this happened. Two measurements will: * **Batch-function invocations against keys loaded.** 516 keys in 12 invocations is healthy. 516 keys in 400 invocations is fragmentation, whatever the wall clock says. * **The distribution of keys per batch.** A pile of one-key batches is the signature. It is the same N+1 you installed the loader to fix, now wearing the loader's clothes. If your platform exposes per-loader counters, read them per request rather than as an average — one badly shaped field is invisible in a mean. ## The fixes, best first **Hoist the pre-work out of the per-item resolver.** The policy above does not vary per locker; it varies per hub. Fetch it once during request setup and put it on the context, and the resolver has nothing to await before loading. Most real cases are this case: config, feature flags, the viewer's permissions, a tenant record. **Issue the load first, then await.** When the pre-work genuinely cannot move, reorder the resolver so the key is queued before you yield. `load` is cheap and side-effect-light; you can queue it and decide later whether you want the value. **Put the pre-work behind its own loader.** If the check really is per-item, it is itself an N+1 — give it a loader and let both batch in the same round. ## The second batch that is not a bug Distinguish the accidental split from the necessary one, because interviewers use it as the follow-up. When a resolver needs the *result* of one load to compute the *key* of the next — locker to courier to that courier's home hub — a second dispatch round is unavoidable and correct. One dispatch per dependency level is the pattern working. What is a defect is a second round caused by an await that had nothing to do with the keys. The diagnostic question is simply: *did this await produce the key I am about to load?* If yes, the extra round is the cost of the dependency. If no, the await is in the wrong place. ## Anti-patterns to name and reject Adding a delay so dispatch waits for the stragglers "fixes" the counter and pays that delay on every request, on every level, for every user. Raising a maximum batch size does nothing when the batches contain one key each. Turning off the loader's memoization is unrelated and only removes work the loader was doing correctly. And rewriting the resolver to fetch directly, because "the loader isn't helping", ships the N+1 for good.

  • When is a second dispatch round correct rather than a defect?
    When the await produced the key you are about to load. Locker to courier to that courier's home hub cannot be one round: the hub id does not exist until the courier arrives, so one dispatch per dependency level is the pattern working as designed. The defect is a second round caused by an await that had nothing to do with the key — a policy lookup, a feature flag, a log write.
  • What would you measure to prove a batch is being fragmented?
    Batch-function invocations against keys loaded, per request rather than averaged, plus the distribution of keys per batch. 516 keys in 12 invocations is healthy; 516 keys in 400 invocations, most of them one key wide, is fragmentation. Total latency hides it, because each tiny fetch is fast on its own.
  • The pre-work genuinely varies per item and cannot be hoisted. Now what?
    Then the pre-work is itself an N+1 and deserves its own loader. Give the per-item check a loader, load both keys in the same pass without awaiting between them, and await the two pending results afterwards. Both loaders then dispatch in the same round, and you are back to two batches rather than eighty-six calls.

You step out of the boarding queue to take a phone call; the shuttle leaves with everyone who stayed, and you catch the next one on your own.

saying these in an interview costs you the question

  • Blames the loader's maximum batch size for the split
  • Thinks an await pauses dispatch until the resolver resumes
  • Adds a dispatch delay instead of moving the await
  • Cannot tell a dependent second batch from an accidental one
  • Judges it by total latency, never by batch invocations
  • Disables memoization hoping the batches will merge

context