Awaiting Happens Inside the Resolver
Awaiting Happens Inside the Resolver
A resolver may return a value or a promise, and the executor treats both the same: it awaits the promise and uses the settled value for that field. The schema does not care whether Post.author arrives from memory or from a three-hop network call.
const resolvers = {
Post: {
title: (post) => post.title, // plain value
author: (post, _a, ctx) => ctx.dataSources.authors.byId(post.authorId), // promise
},
};
Two failure shapes are worth separating, because they look identical in review:
// BUG: the async function returns before the work finishes
updateScore: async (post, _a, ctx) => {
ctx.scores.increment(post.id); // no await — it may reject after we return
return post;
};
// CORRECT
updateScore: async (post, _a, ctx) => {
await ctx.scores.increment(post.id);
return post;
};
In the buggy version the executor resolves the field immediately. When increment rejects afterward there is no field left to attach the error to, so it surfaces as an unhandled rejection in the process: a log line nobody reads, a crashed worker under some runtimes, and a mutation the client was told succeeded. Awaiting inside the resolver is what puts the failure on the right field.
That same missing await quietly voids ordering promises, since serial root execution only waits on what a resolver returns — see serial execution.
What the executor does with a rejection after it arrives is resolver error propagation.
Parallelism You Get Free, and the Kind You Must Build
Parallelism You Get Free, and the Kind You Must Build
Sibling fields in a selection set are independent, so the executor issues them concurrently:
{
me { # three requests in flight together
name
cart { items { sku } }
recommendations { sku }
}
}
me.name ──────────────►
me.cart ──────────────► total ≈ slowest branch, not the sum
me.recommendations ──────────────►
That parallelism costs nothing and needs no code. What you do not get is concurrency across levels of the tree — the executor cannot call me.cart before me resolves — or concurrency inside one resolver. Both are yours to build, and the classic mistake serializes them with await in a loop:
// one wave per id: 20 round trips end to end
const users = [];
for (const id of userIds) {
users.push(await ctx.dataSources.users.byId(id));
}
// better: one wave total
const users = await Promise.all(
userIds.map((id) => ctx.dataSources.users.byId(id))
);
With independent fetches, Promise.all replaces N sequential round trips with a single burst. The failure semantics matter: Promise.all rejects on the first rejection while the other promises are still in flight, which is correct when the field is all-or-nothing. When each item should succeed or fail on its own — a batch import, a list of independent checks — settle them individually:
const settled = await Promise.allSettled(userIds.map((id) => loadUser(id)));
const users = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value);
const failures = settled.filter((r) => r.status === 'rejected').length;
The rule of thumb: siblings fetch in parallel for free; anything inside a single resolver needs an explicit Promise.all (all-or-nothing) or Promise.allSettled (per-item outcomes).
Bounding the Fan-Out
Bounding the Fan-Out
Promise.all over five hundred ids opens five hundred concurrent database or HTTP calls. The dependency sees a thundering herd, the connection pool's ten slots admit a fraction of them, and queueing makes latency worse than the sequential version it replaced. Bound the fan-out instead:
async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++; // claim an index
results[i] = await fn(items[i]); // one in flight per worker
}
}
const workers = Math.min(limit, items.length);
await Promise.all(Array.from({ length: workers }, worker));
return results;
}
const users = await mapWithConcurrency(userIds, 8, (id) =>
ctx.dataSources.users.byId(id)
);
Eight workers keep a steady pipeline instead of a burst: throughput stays high, the pool is not flooded, and results[i] still lines up with items[i].
Choosing the right tool:
| Situation | Approach |
|---|---|
| Same lookup for many ids in one request | DataLoader batching (n+1 pattern) |
| Independent heterogeneous calls, all-or-nothing | Promise.all |
| Independent calls, per-item outcomes | Promise.allSettled |
| Large fan-out against a limited resource | bounded workers, as above |
| Writes that must happen in order | do not fan out — chain them |
Speed is only half the job. A fire-and-forget promise — one started and never awaited — hides both its result and its rejection: the work may still be running when the response ships, and a later failure reaches no field at all. Keep risky calls inside the resolver's await chain, or attach a handler explicitly before returning.
Interview angle: "this resolver takes four seconds, fix it" has three standard moves in order — find the await-in-loop and collapse it with Promise.all, batch repeated lookups with a loader, then bound whatever concurrency you just created. Naming all three shows you have done the whole job rather than the first half.
Practice Problems
A resolver loads a post, then loads each comment author one at a time in a for loop, and the page takes seconds. Rewrite it so authors load concurrently but no more than eight requests are in flight at once, preserving the input order in the returned list.
Example:
Input: comments = [c1, c2, c3] with author ids [u1, u2, u3], each lookup 50ms
Output: returns [author1, author2, author3] in ~50-100ms instead of ~150ms, order preserved
The lookups overlap instead of stacking, and results are written back by index so ordering does not depend on completion order.
Optimal Solution — O(n) time, O(n) space
Map ids to promise slots written by index, run a fixed number of workers that claim the next index, and await all workers together.
async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]); // one in flight per worker
}
}
const workers = Math.min(limit, items.length);
await Promise.all(Array.from({ length: workers }, worker));
return results;
}
const resolvers = {
Post: {
comments: async (post, _args, ctx) => {
const comments = await ctx.dataSources.comments.byPost(post.id);
const authors = await mapWithConcurrency(
comments.map((c) => c.authorId),
8,
(id) => ctx.dataSources.users.byId(id),
);
return comments.map((c, i) => ({ ...c, author: authors[i] }));
},
},
}; Edge Cases:
- Empty comments array: workers count becomes 0, so guard or let min() handle it
- One rejected lookup rejects the whole field via Promise.all — acceptable only if the field is all-or-nothing
- Duplicate author ids should hit the loader cache rather than issuing duplicate queries
Quiz
1. A resolver returns a promise. What does the executor do with it?
2. Twenty ids are loaded with await inside a for loop, each taking 5ms. About how long does that take, and what fixes it?
3. What distinguishes Promise.all from Promise.allSettled for resolver fan-out?
4. Why not call Promise.all over a thousand downstream ids at once?
Flashcards
Question
Executor and promises
Click to reveal answer
Answer
A field finishes when its returned promise settles; the resolved value is used for that field. Async resolvers need no special declaration.
Question
Free parallelism
Click to reveal answer
Answer
Sibling fields in one selection set run concurrently. Levels of the tree cannot overlap, and nothing inside a single resolver runs concurrently.
Question
await-in-loop cost
Click to reveal answer
Answer
Sequential round trips add up (n x latency). Map to promises first, then await Promise.all for one wave — then bound the wave if n is large.
Question
all vs allSettled
Click to reveal answer
Answer
all: first rejection fails the field, other work continues unobserved. allSettled: per-item status, for partial success reporting.
Question
Unawaited async work
Click to reveal answer
Answer
Returns early, so later rejections attach to no field: silent data loss plus an unhandled rejection. Always await inside the resolver.
Revision Notes
Key Takeaways
- 1. The executor awaits resolver promises — concurrency inside a resolver only exists if you build it
- 2. Sibling fields fan out for free; await-in-loop is where accidental serialization hides
- 3. Promise.all for all-or-nothing, Promise.allSettled for per-item outcomes, bounded workers for large fan-out
- 4. Never return before the work is awaited: unobserved rejections become unreported failures
Interview Tips
- • Walk the optimization in order when asked to speed up a slow resolver: collapse sequential awaits, batch repeated lookups, then cap concurrency
- • Be ready to state Promise.all failure semantics precisely — first rejection wins, siblings keep running — and when allSettled is the correct choice
Cheat Sheet
Async Resolver Cheat Sheet
// free: sibling fields run concurrently — no code needed
// inside a resolver: one wave
const users = await Promise.all(ids.map((id) => ctx.users.byId(id)));
// per-item outcomes
const settled = await Promise.allSettled(ids.map((id) => load(id)));
// bounded fan-out
async function pool(items, limit, fn) { /* workers claim next index, await fn */ }
await mapWithConcurrency(ids, 8, (id) => ctx.users.byId(id));
- Field value = awaited promise; plain values skip the wait
awaitin a loop = n x latency;Promise.all≈ one round tripallrejects fast (all-or-nothing) ·allSettledreports per item- Cap concurrency for big fan-outs; batch repeated lookups with DataLoader
- Unawaited work = unreported failures; await or attach a handler