← Notes

Notes · July 2026 · Node.js, GraphQL

await isn’t free.

In a high-throughput GraphQL service, event-loop time goes to a pattern that looks free in code review: awaiting promises that resolve immediately from a DataLoader cache.

The symptom

Event-loop utilization climbs under load, tail latency follows, and no resolver dominates the flame graph. Time is spread thinly across promise plumbing and executor internals instead.

The pattern

DataLoader batches and memoizes per-request lookups, so repeated load(id) calls collapse into one backend fetch: the first hits the backend, every repeat resolves from memory. In a list-heavy schema, hit rates are high. The resolver reads as if the cached case were free:

// Looks free when the loader has it cached. It isn't.
const resolvers = {
  Post: {
    author: (post, _, { loaders }) => loaders.user.load(post.authorId),
  },
};

What an await costs

Awaiting an already-resolved promise still suspends the function and schedules its continuation on the microtask queue. V8 has optimized this hard (Node 12 cut it from three ticks to one), but one tick isn’t zero. Measured: a million awaits of a resolved promise take ~35ms; reading the value directly takes under 3ms.

const N = 1_000_000;
const cache = new Map([["k", 42]]);

async function viaAwait() {
  let sum = 0;
  for (let i = 0; i < N; i++) {
    sum += await Promise.resolve(cache.get("k"));
  }
  return sum;
}

function viaSync() {
  let sum = 0;
  for (let i = 0; i < N; i++) {
    sum += cache.get("k");
  }
  return sum;
}

// node v24: viaAwait ~35ms, viaSync ~2.8ms — roughly 13x,
// or ~35ns of scheduling overhead per cached await.

Absolute numbers vary by engine — Node and each browser will give you different constants. The asymmetry holds everywhere.

Why it compounds

That benchmark is the floor. In a GraphQL executor two things compound it. Promise allocation adds GC pressure. And graphql-js has a synchronous fast path: a resolver returning a plain value completes the field synchronously, while a returned promise forces async completion for the field and every ancestor. A process serving a few thousand requests per second with a few hundred cached fields each is doing hundreds of thousands of useless microtask hops per second. None show up as a hot function; all show up as event-loop utilization.

The fix

Two changes. First, don’t mark resolvers async unless they await something; async (post) => post.title allocates a promise per call for nothing. Second, give cache hits a synchronous path. DataLoader’s cache stores promises, so load() always returns one. A wrapper that memoizes resolved values can return the plain value:

// values: a per-request Map of id -> resolved entity
function loadSync(loader, values, id) {
  if (values.has(id)) return values.get(id); // plain value — sync path
  return loader.load(id).then((value) => {
    values.set(id, value);
    return value;
  });
}

const resolvers = {
  Post: {
    author: (post, _, { loaders, userValues }) =>
      loadSync(loaders.user, userValues, post.authorId),
  },
};

Misses still batch through DataLoader. Hits skip the microtask queue and keep the executor synchronous.

The tradeoff

It’s uglier than the naive version, and at low traffic the naive version is right: the overhead exists but doesn’t matter. The side map also doesn’t see loader.clear(id), so code that clears loaders after mutations must clear both. The contract changes too, from always-a-promise to value-or-promise, so callers that assume the DataLoader shape need to know. Apply the wrapper only where measurement says the multiplier is large — the few entity types that dominate traffic.

The principle

The cost of await depends on throughput. At ten requests a second it rounds to zero; at thousands it shows up on the graph. You can’t tell which regime you’re in by reading code. Measure, then fix what the profile points at.