npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

graphql-breadth

v0.1.0

Published

Breadth-first GraphQL executor

Readme

graphql-breadth

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.

Benchmarks

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.

Flat list

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 |

Tree within list

# 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 |

List with batched lazy field (DataLoader promises)

query { widgets(first: N) { id lazy } }  # N promises

Speed

| 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 |

Deep flat tree (no breadth)

query { widget { widget { widget { id } id } id } }  # depth D

Speed

| 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 |

Where each executor wins

  • 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 ExecutionField per 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

Caveats

  • 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.

Install

npm install graphql-breadth graphql

Quick start

import { 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

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"),
  },
};

Errors

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.

Lazy batching

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 — when true, perform's return value IS the mapped result array. When false (the default), the return value is ignored and the implementation calls fulfillKey(key, result) (or fulfillIdentity) for each key it resolves.
  • async — when true, the loader implements performAsync instead of perform, and the executor awaits the returned Promise before resolving any waiting fields. When false (the default), perform runs 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));
  }
}

Abstract types

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.

Planning phase

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"] : [] });
  }
}

GraphQL JS resolvers

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.path is 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. resolveType and isTypeOf returning a Promise throw an ImplementationError.

Development

pnpm install
pnpm test      # jest
pnpm run build # emits dist/