Skip to content
intermediate Phase 4 · Resolvers and Data Source Integration

Asynchronous Resolvers and Concurrency

Awaiting promises correctly and controlling parallel work inside a request.

45m
1 problems
Topic Progress 0%

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

0 / 1 solved
Parallelize a Sequential Fan-Out Resolver
Concurrent Fetching

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?

Question 1 options

2. Twenty ids are loaded with await inside a for loop, each taking 5ms. About how long does that take, and what fixes it?

Question 2 options

3. What distinguishes Promise.all from Promise.allSettled for resolver fan-out?

Question 3 options

4. Why not call Promise.all over a thousand downstream ids at once?

Question 4 options

Flashcards

Question

Executor and promises

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

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

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

Answer

all: first rejection fails the field, other work continues unobserved. allSettled: per-item status, for partial success reporting.

Question

Unawaited async work

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
  • await in a loop = n x latency; Promise.all ≈ one round trip
  • all rejects fast (all-or-nothing) · allSettled reports per item
  • Cap concurrency for big fan-outs; batch repeated lookups with DataLoader
  • Unawaited work = unreported failures; await or attach a handler

Continue Learning