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.