A basic breadth-first GraphQL executor based on Shopify's Cardinal engine. Written in TypeScript, built on top of graphql-js for parsing, type system, and input coercion.
Unlike graphql-js depth traversal, this executor operates breadth-first: every object at a given depth resolves the same field together, then the executor descends a level. This allows per-field overhead to amortize across the entire breadth of a level, and lets lazy loads batch using one Promise per selection rather than per field instance.
JavaScript implementation is experimental.
Speed: single-machine numbers from pnpm run bench:* on an M2 MacBook Air, Node 22.
GC pressure: numbers from pnpm run mem:*. The metric is wall-clock time V8 spent in GC during the run, divided by iterations — a direct proxy for allocation volume. Lower is better. (Heap bytes/iter would be more direct, but V8 triggers GC mid-run on workloads this size, so GC time is the more reliable signal.)
Some comparisons drop rows due to weak signal.
query { widgets(first: N) { id } }Speed
| size | graphql-js | graphql-breadth | ratio |
|---|---|---|---|
| 1 | 603k i/s | 453k i/s | graphql-js 1.33× faster |
| 10 | 157k i/s | 236k i/s | breadth 1.50× faster |
| 100 | 20k i/s | 42k i/s | breadth 2.13× faster |
| 1000 | 2.1k i/s | 4.4k i/s | breadth 2.13× faster |
| 10000 | 202 i/s | 449 i/s | breadth 2.23× faster |
GC pressure
| size | graphql-js | graphql-breadth | ratio |
|---|---|---|---|
| 100 | 1.9µs/iter | 0.7µs/iter | breadth 2.80× less GC |
| 1000 | 9.0µs/iter | 2.1µs/iter | breadth 4.39× less GC |
| 10000 | 239µs/iter | 31µs/iter | breadth 7.71× less GC |
# inner tree depth D
query { widgets(first: N) { widget { widget { id } id } id } }Speed
| D × N | graphql-js | graphql-breadth | ratio |
|---|---|---|---|
| 1 × 10 | 87k i/s | 162k i/s | breadth 1.86× faster |
| 1 × 100 | 10k i/s | 29k i/s | breadth 2.79× faster |
| 1 × 1000 | 1.1k i/s | 3.1k i/s | breadth 2.90× faster |
| 5 × 10 | 25k i/s | 45k i/s | breadth 1.79× faster |
| 5 × 100 | 2.7k i/s | 6.9k i/s | breadth 2.56× faster |
| 5 × 1000 | 273 i/s | 740 i/s | breadth 2.71× faster |
GC pressure
| D × N | graphql-js | graphql-breadth | ratio |
|---|---|---|---|
| 1 × 100 | 3.2µs/iter | 0.6µs/iter | breadth 5.07× less GC |
| 1 × 1000 | 14µs/iter | 3.7µs/iter | breadth 3.94× less GC |
| 5 × 100 | 6.1µs/iter | 1.1µs/iter | breadth 5.64× less GC |
| 5 × 1000 | 52µs/iter | 12µs/iter | breadth 4.20× less GC |
query { widgets(first: N) { id lazy } } # N promisesSpeed
| size | graphql-js | graphql-breadth | ratio |
|---|---|---|---|
| 1 | 285k i/s | 290k i/s | breadth 1.02× faster |
| 10 | 66k i/s | 137k i/s | breadth 2.10× faster |
| 100 | 7.9k i/s | 22k i/s | breadth 2.82× faster |
| 1000 | 758 i/s | 2.4k i/s | breadth 3.20× faster |
| 10000 | 47 i/s | 244 i/s | breadth 5.18× faster |
GC pressure
| size | graphql-js | graphql-breadth | ratio |
|---|---|---|---|
| 100 | 6.2µs/iter | 0.3µs/iter | breadth 18.7× less GC |
| 1000 | 110µs/iter | 2.3µs/iter | breadth 47.1× less GC |
| 10000 | 5247µs/iter | 44µs/iter | breadth 119× less GC |
query { widget { widget { widget { id } id } id } } # depth DSpeed
| depth | graphql-js | graphql-breadth | ratio |
|---|---|---|---|
| 1 | 866k i/s | 552k i/s | graphql-js 1.57× faster |
| 5 | 235k i/s | 120k i/s | graphql-js 1.95× faster |
| 10 | 125k i/s | 62k i/s | graphql-js 2.02× faster |
| 18 | 69k i/s | 34k i/s | graphql-js 2.00× faster |
GC pressure
| depth | graphql-js | graphql-breadth | ratio |
|---|---|---|---|
| 1 | 0.3µs/iter | 0.2µs/iter | breadth 1.22× less GC |
| 5 | 1.1µs/iter | 0.5µs/iter | breadth 2.37× less GC |
| 10 | 1.2µs/iter | 0.4µs/iter | breadth 2.89× less GC |
| 18 | 2.3µs/iter | 0.6µs/iter | breadth 4.13× less GC |
- graphql-js wins on speed for deep, narrow queries — every level holds one object, so breadth-first never engages, and graphql-js's tight inner loop runs unopposed. However, lacking repetition means the disadvantage doesn't scale, so is negligible (~15µs vs ~30µs as a one-time cost per query, even at depth 18).
- graphql-breadth wins on speed once a level holds multiple objects. The win grows with breadth (2–2.5× at 100+) because per-field work amortizes across the level instead of repeating per object.
- graphql-breadth wins on GC pressure in every shape tested, even the deep-narrow case where graphql-js wins on speed. One long-lived
ExecutionFieldper level allocates less than one short-lived frame per resolution. - The lazy field case is the headline. graphql-js + DataLoader pays a Promise per value per leaf; the breadth-first lazy queue drains synchronously inside the executor — no Promise allocations on the hot path. At 10k objects the GC gap is 119× (5247µs vs 44µs/iter), and graphql-js spends ~25% of its wall-clock in GC (5247µs of 21277µs/iter at 47 i/s).
Run the benchmarks yourself:
pnpm install
SIZES=1,10,100,1000,10000 pnpm run bench:list
FIELDS=lazy SIZES=1,10,100,1000,10000 pnpm run bench:list
DEPTHS=1,5,10,18 pnpm run bench:tree
DEPTHS=1,5 BREADTHS=10,100,1000 pnpm run bench:tree-list
pnpm run mem:tree
pnpm run mem:list
FIELDS=lazy pnpm run mem:list
pnpm run mem:tree-list- No subscriptions, defer, or stream. Theoretically possible with major rearchitectures.
- Breadth-first assumes that all objects at a level want to batch. If you have slowest-object-blocking concerns, then you want traditional tree-based resolution.
npm install graphql-breadth graphqlimport { buildSchema } from "graphql";
import { Executor, ObjectKeyResolver } from "graphql-breadth";
const schema = buildSchema(`type Query { hello: String }`);
const { result } = Executor.build({
schema,
document: `{ hello }`,
resolvers: {
Query: {
hello: new ObjectKeyResolver("hello"),
},
},
rootObject: { hello: "world" },
});
console.log(result); // { data: { hello: "world" } }An executor is built with a resolver map that keys { TypeName => { fieldName => new FieldResolver() } } to provide all schema field resolvers. Additional options:
Executor.build({
schema, // GraphQLSchema (from graphql-js)
document, // string | DocumentNode
resolvers, // ResolverMap
rootObject, // unknown, optional
context, // unknown, optional - passed to resolvers
variables, // Record<string, unknown>, optional
operationName, // string | null, optional
validateDocument, // boolean, default true - skip if pre-validated
});Resolvers receive an execField with all field state, including objects, arguments, and context. A resolver must return a mapped set of results derived from execField.objects. Returning results with unmatched cardinality is a programming error.
import { FieldResolver } from "graphql-breadth";
import type { ExecutionField } from "graphql-breadth";
class FullName extends FieldResolver {
resolve(execField: ExecutionField) {
if (!execField.context.authorized) return execField.resolveAll(null);
return execField.mapObjects((user) => `${user.firstName} ${user.lastName}`);
}
}Built-in resolvers are provided to cover common cases:
import {
ObjectKeyResolver, // obj[key]
MethodResolver, // obj.method() or obj.a.b.c
SelfResolver, // returns the object itself
ValueResolver, // returns a constant
} from "graphql-breadth";
const resolvers = {
User: {
id: new ObjectKeyResolver("id"),
fullName: new FullName(),
age: new MethodResolver("computeAge"),
self: new SelfResolver(),
apiVersion: new ValueResolver("v2"),
},
};Map error instances into resolver results, or throw an ExecutionError within a mapObjects loop:
import { ExecutionError } from "graphql-breadth";
class SecretField extends FieldResolver {
resolve(execField) {
if (!execField.context.authenticated) throw new ExecutionError("Not authorized");
return execField.mapObjects(
(obj) => obj.allow() ? obj.secret : new ExecutionError("Not authorized"),
);
}
}Raising an ExecutionError outside of mapObjects will fail the field across all objects. Unhandled exceptions will terminate all execution.
Breadth-based fields receive all objects at once, so are implicitly batched. However, lazy batching is still useful when pooling I/O across separate field selections. A LazyLoader can pool an entire key set into a single lazy promise. That means only one promise is built per document selection, versus per field instance in graphql-js.
import { LazyLoader, FieldResolver } from "graphql-breadth";
class UserById extends LazyLoader {
map = true; // perform returns results 1:1 with keys
perform(ids: string[]): User[] {
return db.usersWhereIdIn(ids); // one query for the entire level
}
}
class Author extends FieldResolver {
resolve(execField) {
return execField.lazy({
loaderClass: UserById,
keys: execField.mapObjects((post) => post.authorId),
});
}
}Two orthogonal flags configure how a loader delivers its results:
map— whentrue,perform's return value IS the mapped result array. Whenfalse(the default), the return value is ignored and the implementation callsfulfillKey(key, result)(orfulfillIdentity) for each key it resolves.async— whentrue, the loader implementsperformAsyncinstead ofperform, and the executor awaits the returned Promise before resolving any waiting fields. Whenfalse(the default),performruns synchronously and the executor drains the lazy queue without yielding to the microtask queue.
Async loaders are the canonical way to plug remote I/O into the breadth model. Because performAsync is called once per document selection — not per field instance — a list of N objects produces a single Promise, regardless of N:
class UserByIdAsync extends LazyLoader {
async = true;
map = true;
async performAsync(ids: string[]): Promise<User[]> {
return await db.usersWhereIdIn(ids); // one round-trip for the whole level
}
}When passing in lazy keys, null keys may be submitted to hold a results position. These will get dropped from the loader set and pass through as null results. Chain a post-load callback with .then(...):
class AuthorName extends FieldResolver {
resolve(execField) {
return execField
.lazy({ loaderClass: UserById, keys: ... })
.then((users) => users.map((u) => u.name));
}
}Awaiting and chaining is also supported:
class FancyLazy extends FieldResolver {
resolve(execField) {
pendingPosts = execField
.lazy({ loaderClass: UserById, keys: ... })
.then((users) => execField.lazy({ loaderClass: PostsById, keys: users }));
pendingPromos = execField
.lazy({ loaderClass: PromosById, keys: ... })
return execField.awaitAll([pendingPosts, pendingPromos])
.then((posts, promos) => posts.zip(promos));
}
}For interfaces and unions, attach a __type__ resolver that maps an object to its concrete type:
const resolvers = {
Character: {
__type__: (obj) => obj.kind === "droid" ? schema.getType("Droid") : schema.getType("Human"),
id: new ObjectKeyResolver("id"),
},
};Without __type__, the executor falls back to reading __typename off the object.
Before execution begins, the planner walks the tree bottom-up and calls plan() on every field's resolver. A field's children plan first, so they can annotate their ancestors with dependency information. Both execution scopes and fields have an attributes Map for sharing state between resolvers in the same planning pass.
// Field resolver for `Widget.sprockets`.
// Plans first and annotates the scope's parent field to include sprockets.
class Sprockets extends FieldResolver {
plan(execField) {
// Tell the scope above to join sprockets.
execField.scope.parentField.attributes.set("includeSprockets", true);
}
resolve(execField) {
return execField.mapObjects((widget) => widget.sprockets);
}
}
// Field resolver for `Query.widgets`.
// Can check itself for annotations passed upwards by children.
class Widgets extends FieldResolver {
resolve(execField) {
const includeSprockets = execField.attributes.get("includeSprockets") === true;
return db.widgets({ join: includeSprockets ? ["sprockets"] : [] });
}
}For schemas built with graphql-js's executable schema pattern (where each field carries a resolve function with the (source, args, context, info) signature), interpretSchema walks the schema and produces a ResolverMap whose entries delegate to those resolvers. Pass the result to Executor.build to run an existing graphql-js schema through the breadth executor unchanged:
import { Executor, interpretSchema } from "graphql-breadth";
import { schema } from "./my-graphql-js-schema";
const { result } = Executor.build({
schema,
document: `{ hero { name } }`,
resolvers: interpretSchema(schema),
});Mix interpreted and native resolvers by passing a breadth-native ResolverMap as the second argument. Entries are merged field-by-field over the interpreted defaults, so native resolvers retain their breadth-first advantages (single invocation per level, lazy batching, planning) while the rest of the schema runs through the per-object interpreter:
const resolvers = interpretSchema(schema, {
User: {
posts: new PostsLoader(), // batched native resolver
},
});Support notes:
- Resolvers that return native Promises are awaited together as one breadth-loader cycle via
InterpretedPromiseLoader, so a list of N async resolvers still yields once, not N times. This is generally compatible though may produce different results for situations designed around a depth-based execution flow. - Accessing resolver
info.pathis not supported. Breadth has no concept of runtime subtrees (though this gap is possible to fill with overhead). - No support for lazy abstract type resolution.
resolveTypeandisTypeOfreturning aPromisethrow anImplementationError.
pnpm install
pnpm test # jest
pnpm run build # emits dist/