Most GraphQL performance conversations eventually end up at dataloaders. The N+1 problem is still a very real one, and batching database or API calls is still one of the easiest ways to stop a GraphQL server from doing obviously wasteful work.
Which is why the release of graphql-breadth-js is a really interesting topic to talk about and enable it to gain traction! The current GraphQL (default) execution algorithm mostly assumes the following shape:
resolve(parent, args, context, info) => value | Promise<value>
It is an easy abstraction to reason about, that is probably why it prevailed. Every field gets a parent object, args, context, and metadata. Schema builders like Pothos and Grats can type it nicely. Server frameworks know how to execute it. Plugins can wrap it for auth, tracing, logging, caching, permissions, and all the other things a real API eventually grows.
The catch is that the contract is per object, when a query asks for variants on 100 products, the normal model thinks about 100 separate Product.variants resolver calls. A dataloader can batch the actual data access, but the executor still walks through a lot of tiny calls, promises, wrappers, and bookkeeping.
graphql-breadth asks a different question: what if the field resolver received all the parent objects for that field at the current level? The shape of our resolver conceptually becomes:
resolve(execField) => values[] | ExecutionPromise<values[]>
The important bit is execField.objects. Instead of resolving Product.variants once per product, the field can receive every product at that level and return one result per product, in the same order.
The difference in API looks very small, however it’s a whole different world. It moves batching into the execution model.
With a dataloader, you often still create work per field instance and hope a later queue merges the underlying load. With breadth-first execution, the level itself becomes the unit of work. One field, many parents, one operation.
That matters for runtime overhead too. The interesting benchmark is not just "the database call got faster." It is less promise churn, less wrapper overhead, and less GC pressure on broad lazy-loading shapes. In a large API, that hidden cost adds up. The hard part here becomes adoption as our previous resolve pattern has very wide adoption in the ecosystem.
The new execution algorithm isn’t a silver bullet, as is everything in software engineering I guess. info.path for instance isn’t present, abstract-types can’t be resolved lazily and subscriptions as well as defer/stream aren’t currently implemented.
GraphQLSchema compatibility is not the main blocker, most GraphQL tools can already produce or consume a schema. The mismatch is that the ecosystem assumes the standard resolver contract everywhere around the schema.
That is where Pothos becomes interesting, I noticed that they already have a plugin to support Grafast*. I explored creating a plugin for Pothos and to my surprise, it just worked and had really good performance/memory benefits!
*Another extremely interesting innovation on the execution algorithm with similar goals to graphql-breadth .
A shallow adapter is easy enough. You can inspect an existing schema and wrap normal resolvers so graphql-breadth can call them. That proves the executor can sit underneath ordinary GraphQL JavaScript schemas, however it also does not give you the real win.
When the adapter still maps over every parent and calls the old resolver once per object, the program is technically running under a breadth-first executor, but the hot path is still shaped like normal GraphQL JavaScript. You get compatibility, not the main unlock. The migration path is to let schema builders expose breadth-aware fields directly, for Pothos, the prototype shape ended up at:
builder.objectType(Product, {
fields: (t) => ({
variants: t.field({
type: [Variant],
// Initially
resolve: (parent, args, ctx) => // load variants for single product
// Migrated
breadthResolve: (fields, ctx) => // load variants for multiple products
}),
}),
});
Existing schemas can still run because we support resolve. We can incrementally migrate over fields by adding breadthResolve and the execution algorithm will adapt.
Without that, breadth-first execution asks too much, nobody is going to rewrite a production GraphQL API fully in one go, there’s too much risk involved. The JavaScript ecosystem has too much useful machinery in schema builders, server hooks, plugins, and resolver wrappers to just throw them out the door.
You can start with a normal Pothos schema, you can keep your server as the integration point, you can preserve traditional execution where it works, and start testing the waters where a breadth-resolver would be a crucial improvement.
The traditional execution model is not free, once an API grows enough in usage or schema-complexity, "one resolver invocation per object per field" shows up in places another dataloader does not fix. Promise churn, wrapper overhead, tracing, authorization, plugin composition, and GC all become part of the budget.
The next meaningful GraphQL performance improvement may not come from making dataloaders slightly better or more ergonomic. It will come from admitting that the traditional model does not cut it anymore.
I look forward to seeing how GraphQL Breadth is adopted across the wider GraphQL ecosystem and I hope this at least removes a footgun that people learning GraphQL have to contend with. Let’s make N + 1 problems a thing of the past for users of GraphQL!
