Execution Hooks and Async Cleanup
Execution hooks are experimental in GraphQL.js v17. The current hook surface may change before it becomes stable.
GraphQL execution can stop producing a result before every piece of async work
started by execution has settled. This is most visible with cancellation:
JavaScript does not have preemptive cancellation for promises. An
AbortSignal is cooperative, so it only helps when downstream async functions
accept the signal and honor it.
GraphQL.js cannot force arbitrary JavaScript work to stop. What it can do is
track async work it knows about and tell the host when that tracked work has
finished. The asyncWorkFinished hook is that boundary.
asyncWorkFinished
Pass hooks through execute(), subscribe(), graphql(), or
experimentalExecuteIncrementally().
import { execute } from 'graphql';
await execute({
schema,
document,
hooks: {
asyncWorkFinished({ validatedExecutionArgs }) {
logger.debug(
{
operationName: validatedExecutionArgs.operation.name?.value,
},
'GraphQL async work finished',
);
},
},
});The hook fires after GraphQL.js has stopped producing payloads and all tracked async execution work has settled. It is useful when a host needs to observe work that continues after the response boundary, such as async iterator cleanup, cleanup after an aborted execution, or resolver-started work that was explicitly registered for tracking.
What GraphQL.js can track
GraphQL.js tracks async work that is part of execution and work registered through resolver info helpers. It does not automatically know about arbitrary background work started by your application.
Use promiseAll() when a resolver awaits several async branches and you want
rejected branches to be tracked consistently:
async resolve(_source, args, _context, info) {
const { promiseAll } = info.getAsyncHelpers();
const [user, permissions] = await promiseAll([
loadUser(args.id),
loadPermissions(args.id),
]);
return { user, permissions };
}Use track() for async cleanup or side effects that a resolver starts but does
not return or await:
async resolve(_source, _args, _context, info) {
const { track } = info.getAsyncHelpers();
const cleanup = closeResourceLater().catch(() => undefined);
track([cleanup]);
return 'ok';
}If the resolver is already awaiting or returning the work, do that normally.
Use track() only for work that would otherwise be invisible to GraphQL.js.
Logging and telemetry
For many hosts, the hook is an observability point. It can record how long tracked async cleanup continued after execution started or after a response was produced.
const startedAt = Date.now();
await execute({
schema,
document,
hooks: {
asyncWorkFinished({ validatedExecutionArgs }) {
metrics.record('graphql.async_work_finished', {
operationName: validatedExecutionArgs.operation.name?.value,
elapsedMs: Date.now() - startedAt,
});
},
},
});Waiting before returning a result
Some hosts prefer not to return the GraphQL result to the transport until tracked async cleanup has finished. For example, a test harness may want the operation to leave no pending execution work before assertions run.
import {
executeRootSelectionSet,
validateExecutionArgs,
} from 'graphql';
async function executeAndWaitForAsyncWork(args) {
const validatedArgs = validateExecutionArgs(args);
if (!('schema' in validatedArgs)) {
return { errors: validatedArgs };
}
let markAsyncWorkFinished;
const asyncWorkFinished = new Promise((resolve) => {
markAsyncWorkFinished = resolve;
});
const result = executeRootSelectionSet({
...validatedArgs,
hooks: {
...validatedArgs.hooks,
asyncWorkFinished(info) {
validatedArgs.hooks?.asyncWorkFinished?.(info);
markAsyncWorkFinished();
},
},
});
const executionResult = await result;
await asyncWorkFinished;
return executionResult;
}This pattern trades response latency for a stronger lifecycle boundary. It is usually better for tests, controlled batch jobs, and framework internals than for latency-sensitive HTTP handlers.
Host cleanup
The hook can also release host-owned bookkeeping that should stay alive until GraphQL.js has finished tracked async work.
await execute({
schema,
document,
contextValue: requestContext,
hooks: {
asyncWorkFinished({ validatedExecutionArgs }) {
requestRegistry.delete(validatedExecutionArgs);
requestContext.loaderCache.clear();
},
},
});Aborts and incremental delivery
Hooks are especially useful when execution is aborted or incremental delivery is used:
- Abort may stop payload production before cleanup is complete.
- Async iterator
return()paths can continue after the response boundary. - Deferred work can leave short-lived cleanup tasks after the final patch.
Pair hooks with Handling Abort Signals for timeout and cancellation instrumentation.