In the GraphQL optimization series, we looked at the work between the GraphQL server and its backends. In this post, let’s go one level deeper, into the executor itself.
graphql-jit replaces only the executor of graphql-js. It uses the same schema and resolvers, and for queries that come back again, it runs them several times faster. Where does that speed come from? Most of the answer is in decisions that graphql-js makes again on every request, and graphql-jit makes only once.
graphql-jit compiles each query into a specialized JavaScript function. You cache the compiled query and call it like a normal function to execute it. The decisions it makes once, at compile time, and writes into the generated code as constants include:
Environment:
v0.9.3v17.0.2v24.14.1 (NODE_ENV=production)The benchmark suite runs the same blog query against versions of a blog schema with different numbers of resolvers and complexity levels. The versions have 4 (fewResolvers), 15 (manyResolvers) and 19 (nestedArrays) resolvers, plus the full introspection query, where every field has a resolver. The fewer resolvers a query needs, the bigger graphql-jit’s advantage becomes.
Throughput is measured with the benchmark suite, timing execution only. Compilation happens once, outside the timed loop, and neither side runs parse or validate in it. With JSON serialization of the response included, the gains are 7.5x, 7.4x, 1.8x and 4.8x.
| Benchmark | graphql-js | graphql-jit | Change |
|---|---|---|---|
| fewResolvers | 66K | 1.0M | 15.8x |
| manyResolvers | 42K | 446K | 10.5x |
| introspection | 4.7K | 9.0K | 1.9x |
| nestedArrays | 462 | 2.6K | 5.5x |
In graphql-jit, queries become specialized JS functions. compileQuery walks the query against the schema and builds a JS source string. It then turns that string into a real function.
new Function("return " + generatedSource)();
Throughout this post, let’s follow one query:
query ($compact: Boolean!) {
product(id: "1") {
__typename
id
name
desc @skip(if: $compact)
wishlist {
count
}
}
}
Here, product and wishlist have resolvers, and the other fields are plain properties. A simplified excerpt of what graphql-jit generates for it:
const QueryproductResolverArgs = { id: "1" };
// ... calls the product resolver, then builds
__parent.product = {
// Inlined typename for concrete object type
__typename: "Product",
// Plain property reads for resolver-less fields
id: checkNonNullLeaf(__fieldproduct.id),
name: checkNullableLeaf(__fieldproduct.name),
// Conditional fields based on directives
desc:
Variables["compact"] === false
? checkNullableLeaf(__fieldproduct.desc)
: undefined,
// Resolver-based fields are initially set to null and filled in later
wishlist: null,
};
The sections below refer back to parts of this code.
On every query execution, an interpreter such as graphql-js performs these steps:
@skip and @include directives, so it knows exactly which fields need to be resolved.graphql-jit does the static part of steps 2-4 once, at compile time. It merges fragments, collects fields and subfields, and compiles each field’s type handling (compileType). Anything that depends on the request is left as a small runtime expression: @skip/@include conditions on variables, arguments that use variables, and the resolve info for fields that have resolvers. For concrete object types, each field becomes straightforward line of code. Interfaces and unions still determine their concrete type at runtime.
V8 optimizes any hot function, graphql-js included. The difference between graphql-js and graphql-jit comes from what V8 sees at each property access and call site. graphql-js runs every field of every query through the same executeField and completeValue code, so those sites see many object shapes and many resolvers, and their inline caches can become megamorphic (slow, generic lookups).
In graphql-jit’s generated function, each field has its own property read and its own call. Each of those call sites always sees the same shape, so its inline cache stays monomorphic. The generated code also contains only what the query needs.
In our GraphQL service, we don’t write resolvers for every field. A product resolver fetches the whole product, and fields like id, name, desc, price and attributes are just properties on that object (Lookaheads).
// Example of sparse resolvers in schema
const resolvers = {
Query: {
async product(_, { id }) {
return getProductById(id);
},
},
Product: {
// No explicit resolvers for id, name, desc, price, attributes
// Explicit resolver for Product-Wishlist connection
async wishlist({ id }) {
return getProductWishlistConnection(id);
},
},
};
In an interpreter, every field goes through a field execution step that invokes a default field resolver, a function that just reads the property from the parent object. Because the executor treats every resolver the same way, it allocates a resolve-info object and an args object even for this default resolver, sets up try/catch, and checks for promises before and after completion.
For these resolver-less fields, graphql-jit generates a direct inline property read instead of a resolver call. In the excerpt from section 1, name is just __fieldproduct.name, passed to a small runtime helper (checkNullableLeaf) that handles null, errors, and scalar serialization.
__parent.product = {
name: checkNullableLeaf(__fieldproduct.name, ...),
};
There is no resolver call, no resolve-info object, no args object, and no promise check. The fewResolvers benchmark, where most fields work this way, shows the largest gain (15.8x).
During execution, an interpreter builds each result object from an empty Object.create(null) and adds keys one at a time (results[responseName] = result). Objects created with Object.create(null) start in V8’s dictionary mode, so they never get a hidden class and miss V8’s fast property access optimizations (V8 design elements: fast property access, Fast properties in V8).
In contrast, graphql-jit builds each response object as an object literal with a fixed key order, like the product object in section 1. Deferred fields (fields with resolvers, which may be async) start as null placeholders, like wishlist, so the shape is set before any resolver runs. Filling them in later (__parent.wishlist = ...) does not change the shape. V8 gives every response object from the same selection the same hidden class, and the inline caches stay monomorphic.
Let’s go back to our query. Say the product resolver fetches the product and, to save a round trip, starts the wishlist request in the same place:
const resolvers = {
Query: {
async product(_, { id }) {
const wishlistPromise = getProductWishlistConnection(id);
const product = await getProductById(id);
return {
...product,
wishlist: wishlistPromise, // Promise
};
},
},
};
Both product and wishlist now resolve asynchronously. graphql-js handles this one level at a time:
product resolver returns a promise. graphql-js waits for it and passes the product to completeValue.wishlist is a promise too. So it returns a promise for the whole product object, which resolves once wishlist has resolved.Promise.all.Why so many promises? Because each object only knows about its own children, so it has to wait for them, and its parent has to wait for it. This does not mean a promise for every object or field. Synchronous results can stay synchronous. The extra promises appear along async paths.
graphql-jit requires the promise-valued wishlist property to have an explicit resolver (differences from graphql-js), which is the Product.wishlist resolver from section 2. Async work in graphql-jit does not force completion promises up the tree. Once the explicit resolver returns a promise, graphql-jit tracks the pending work centrally and writes the resolved value into the response.
promiseCounter++;
promise.then(
(result) => {
onSuccess(result);
if (--promiseCounter === 0) resolveFinalResult();
},
(error) => {
onError(error);
if (--promiseCounter === 0) resolveFinalResult();
},
);
Across the benchmarks, the same queries create 63-71% fewer promises. This is measured by counting the PROMISE resources seen by an async_hooks init hook during one execution, including promises created by the benchmark’s own resolvers, which are the same for both executors.
| Benchmark | graphql-js | graphql-jit | Change |
|---|---|---|---|
| fewResolvers | 16 6 | -63% | |
| manyResolvers | 224 64 | -71% |
nestedArrays drops from 27,356 to 9,066 promises per request (-67%). introspection creates no promises in either executor; it is fully synchronous.
Along with the generated function for the query, graphql-jit generates a query-specific variables parser ahead of execution. Like the query function, the variables function is specialized to the query variable definitions. The variables function has the same properties as the compiled query function. Built-in scalars use inline validation rather than generic function calls. The output object has a predictable shape and allows V8 to optimize property access. Custom scalars and enums still use function calls for coercion.
Measured with 5 variables (ID!, Int = 640, Int, [String!], Boolean):
| Benchmark | graphql-js `getVariableValues` | graphql-jit compiled `getVariables` | Change |
|---|---|---|---|
| 5 variables | 0.48 µs 0.071 µs | 6.8x |
GraphQL resolvers receive a GraphQLResolveInfo object containing metadata such as the field name, schema, return type, and response path. graphql-js constructs this object for every field, because any resolver might need it. It can’t know in advance which ones will.
graphql-jit avoids this work for plain property reads. For fields with explicit resolvers, it creates a dedicated info factory during compilation. That factory captures metadata that stays constant across executions, such as the field’s AST node, parent type, and so on. At runtime, it produces a fixed-shape object using those references and request-specific data. The response path is also emitted directly into the generated code.
In our query, only product and wishlist get a resolve-info object. A query that reads 50 plain fields does not build any resolve-info for those fields at all.
Together with the plain property reads from section 2 and the fewer promises from section 4, these skipped allocations add up. Heap allocated per request is measured as the heapUsed delta over N executions after warm-up, with a 512MB young generation so no garbage collection runs during the measurement (checked with --trace-gc).
| Benchmark | graphql-js | graphql-jit | Change |
|---|---|---|---|
| fewResolvers | 70.5 KB 14.5 KB | -79% | |
| manyResolvers | 118.6 KB 36.9 KB | -69% |
nestedArrays drops from 11.6 MB to 4.3-4.4 MB per request (-63%). introspection drops from 1.2 MB to 0.8-0.9 MB (-24% to -34%); its resolvers do much of the allocating themselves, so there is less for graphql-jit to remove.
In an interpreter, extracting the argument values happens at runtime for every field execution.
In graphql-jit, literal arguments are coerced at compile time and emitted into code as literals. In our query, product(id: "1") becomes the constant const QueryproductResolverArgs = { id: "1" }. Values that cannot be written as source are stored in a separate lookup table context.literals instead. Only arguments that reference variables produce runtime code.
@skip / @include become boolean expressionsIn graphql-jit, compileSkipInclude turns directives @skip and @include into JS expressions over the variables. In our query, desc @skip(if: $compact) becomes the condition for including the field,
(__context.variables["compact"] === false), so desc is included only when $compact is false.
A general-purpose executor / interpreter must inspect the field’s directives, identify @skip and @include, obtain the argument value, and decide whether to execute this field. graphql-jit inlines that expression into the generated code, so at runtime, skipping a field costs one comparison.
__typename is inlined__typename field on a concrete object type is known at compile time. graphql-jit compiles it to a string literal, "__typename": "Product" in our query. There is no resolver call and no resolve-info allocation, whereas an interpreter would run it through a normal field pipeline.
Constructing GraphQL errors involves work beyond creating the error’s message. It includes finding the field’s line and column in the source, converting the executing path into an array, and potentially capturing the stack trace. A non-null failure also requires determining which enclosing response branch must become null.
graphql-jit moves most of this preparation to compile time. Source locations and response path templates are emitted into the generated code, and dynamic list indices are filled in during execution. For non-null failures, a precomputed nullability tree identifies which response branches must be removed. Stack traces are still captured by default; the disablingCapturingStackErrors compiler option turns that off.
graphql-jit’s performance comes from a simple principle: “don’t repeat work when the query and schema already tell you the answer”. graphql-jit does not make the application’s work disappear. It removes much of the repeated work by moving it to compile time. That work now happens once, when a query is compiled, instead of over and over again on every request that sends the same query.
graphql-js is the reference implementation, and it has to run any query correctly the first time it sees it, with no compile step. On the other hand, graphql-jit can spend time up front compiling a query, and it takes a few shortcuts for performance, like requiring a compile step, or requiring explicit resolvers for promise-valued fields.
The benchmarks show where moving work to compile time matters most. The more fields a query reads as plain properties, the bigger the gain: 15.8x for fewResolvers, and 1.9x for introspection, where every field has a resolver. And since compiling a query costs something, it pays off when the same queries come back again and again, which is exactly what persisted queries give us.
If you have any comments, corrections, or questions about this post, feel free to reach out to me at @boopathi@mastodon.social, @boopathi.in on bsky.app, or @heisenbugger on X.