A Fast GraphQL API With Fastify and Yoga · Part 8 of 8 in the series
Part 8: Query Limits and Persisted Operations
Protect the API from operations that are too deep or too expensive with a depth limit and a cost limit, and lock it to the operations of your own clients with persisted operations.

Quest objectives
- See how a short query can ask for an enormous answer
- Limit how deep an operation may go
- Estimate what an operation costs before it runs
- Allow only persisted operations in production
- Know what to check before the API goes live
The freedom that makes GraphQL pleasant for clients is also its weak spot. The schema has cycles: a guild has adventurers, an adventurer has a guild, and a client can follow that circle as long as it likes:
$ curl -s -o /dev/null -w '%{size_download} bytes\n' localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guilds { adventurers { guild { adventurers { guild { adventurers { guild { adventurers { guild { adventurers { name } } } } } } } } } } }"}'22378 bytes
139 characters, and the answer is 22 KB, from a board with three guilds of three. With 50 adventurers per guild, the same query asks for 3 × 50⁵ names, almost a billion. DataLoader keeps the database calm, because it is the same few rows over and over, but the server still builds that answer object by object until it runs out of memory. Aliases make it worse: a client can ask for the same expensive field a hundred times in one query, each under another name.
Two limits catch this before anything runs: one on depth, and one on cost.
The depth limit
$ pnpm add @envelop/depth-limit graphql-query-complexity[WARN] 4 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5, lodash.get@4.4.2, node-domexception@1.0.0Packages: +5dependencies:+ @envelop/depth-limit 8.0.0+ graphql-query-complexity 2.0.0Done in 569ms using pnpm v12.16.1
The depth limit is one line in the plugins. The second line is the cost limit, which you write next:
import { useDepthLimit } from '@envelop/depth-limit';
import { useCostLimit } from './cost.ts';
// …
plugins: [
useDepthLimit({ maxDepth: 6 }),
useCostLimit(5_000),The depth limit counts the fields at the top as depth 0, so maxDepth: 6 allows seven levels. That is deep enough for every query on the board and far too shallow for the spiral.
The cost limit
Depth alone is not enough: quests(first: 100) with a few levels below it is not deep, and it is still expensive. A cost limit estimates what an operation will return before it runs. graphql-query-complexity walks through the operation and asks estimators what each field costs. Create src/cost.ts:
import { GraphQLError, getNullableType, isListType, type ExecutionArgs } from 'graphql';
import type { Plugin } from 'graphql-yoga';
import {
getComplexity,
simpleEstimator,
type ComplexityEstimator,
} from 'graphql-query-complexity';
/** A list costs its size times one item: `first` where it is given, a guess of 10 elsewhere. */
const listEstimator: ComplexityEstimator = ({ field, args, childComplexity }) => {
if (!isListType(getNullableType(field.type))) return undefined;
const size = typeof args.first === 'number' ? args.first : 10;
return size * (1 + childComplexity);
};
export function useCostLimit(maxCost: number): Plugin {
/** An error result for an operation over the limit, and nothing for one within it. */
function check(args: ExecutionArgs) {
const cost = getComplexity({
schema: args.schema,
query: args.document,
variables: args.variableValues as Record<string, unknown>,
operationName: args.operationName ?? undefined,
estimators: [listEstimator, simpleEstimator({ defaultComplexity: 1 })],
});
if (cost <= maxCost) return undefined;
return {
errors: [
new GraphQLError(`This operation costs ${cost} points, the limit is ${maxCost}`, {
extensions: { code: 'OPERATION_TOO_EXPENSIVE', cost, maxCost },
}),
],
};
}
return {
onExecute({ args, setResultAndStopExecution }) {
const rejected = check(args);
if (rejected) setResultAndStopExecution(rejected);
},
onSubscribe({ args, setResultAndStopExecution }) {
const rejected = check(args);
if (rejected) setResultAndStopExecution(rejected);
},
};
}The rule is short: every field costs one point, and a list costs its size times one item. The size is first where a field has it, and a guess of 10 for lists without it, like the adventurers of a guild. So { quests { title guild { name } } } is 20 quests × (1 + 3) = 80 points.
The check runs in onExecute and onSubscribe, not as a validation rule. The cost depends on the variables, first: $count can be anything, and Yoga caches the validation of an operation without them. Try it:
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"query Spiral { guilds { adventurers { guild { adventurers { guild { adventurers { guild { adventurers { guild { adventurers { name } } } } } } } } } } }"}'{"errors":[{"message":"'Spiral' exceeds maximum operation depth of 6","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 100) { title guild { adventurers { name quests { title } } } } }"}'{"errors":[{"message":"This operation costs 22300 points, the limit is 5000","extensions":{"code":"OPERATION_TOO_EXPENSIVE","cost":22300,"maxCost":5000}}]}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 2) { title reward guild { name } assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","reward":20,"guild":{"name":"Ember Lions"},"assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","reward":120,"guild":{"name":"Ember Lions"},"assignee":{"name":"Ilse"}}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}
The spiral fails on its depth, the broad query costs 22300 points and is turned away, and the board passes. The guild overview from part 3 costs 2220, and the quest board that a real client sends in a moment costs 180, so 5000 leaves room without letting through anything huge. Subscriptions are checked too. With the limit set to 100 for a moment, a subscription on new quests with their guild's adventurers and their quests gets an error event and ends:
event: nextdata: {"errors":[{"message":"This operation costs 212 points, the limit is 100","extensions":{"code":"OPERATION_TOO_EXPENSIVE","cost":212,"maxCost":100}}]}event: completedata:
Persisted operations
Limits guard against queries you did not foresee. If the API serves only your own frontend, you can go further, because then you know every operation it sends. Your build collects them into a list, every operation under the hash of its text. The client sends only the hash and the variables, and the server runs only operations from its list. Nobody else's query ever runs, the requests are small, and a GET with a hash makes a short address that a CDN can cache.
$ pnpm add @graphql-yoga/plugin-persisted-operationsPackages: +1dependencies:+ @graphql-yoga/plugin-persisted-operations 4.0.1Done in 271ms using pnpm v12.16.1
The operations go in an operations folder, one file each, as your frontend would write them. Create operations/QuestBoard.graphql:
query QuestBoard($status: QuestStatus) {
quests(status: $status, first: 20) {
id
title
reward
status
guild {
name
}
assignee {
name
}
}
}And operations/AcceptQuest.graphql:
mutation AcceptQuest($questId: ID!, $adventurerId: ID!) {
acceptQuest(questId: $questId, adventurerId: $adventurerId) {
id
status
assignee {
name
}
}
}A small script hashes them into persisted-operations.json. Create scripts/persist-operations.ts:
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
const operations: Record<string, string> = {};
for (const file of readdirSync('operations')) {
const document = readFileSync(`operations/${file}`, 'utf8');
const hash = createHash('sha256').update(document).digest('hex');
operations[hash] = document;
console.log(`${hash.slice(0, 12)}… ${file}`);
}
writeFileSync('persisted-operations.json', `${JSON.stringify(operations, null, 2)}\n`);Add the script to package.json and run it:
"persist": "node scripts/persist-operations.ts"$ pnpm persistbde9c83149a0… AcceptQuest.graphqlea91ca7a237e… QuestBoard.graphql
{
"bde9c83149a07f4ca734c542eff70efe7895c7017185941b80ad368ce1a0817b": "mutation AcceptQuest($questId: ID!, $adventurerId: ID!) {\n acceptQuest(questId: $questId, adventurerId: $adventurerId) {\n id\n status\n assignee {\n name\n }\n }\n}\n",
…
}Now the server reads the list and runs only what is on it. This is src/server.ts as it is at the end of the series:
import { readFileSync } from 'node:fs';
import Fastify, { LogController } from 'fastify';
import { createYoga } from 'graphql-yoga';
import { useExtendContextValuePerExecuteSubscriptionEvent } from '@envelop/execute-subscription-event';
import { useResponseCache } from '@graphql-yoga/plugin-response-cache';
import { useDepthLimit } from '@envelop/depth-limit';
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations';
import { Logger } from '@graphql-hive/logger';
import { PinoLogWriter } from '@graphql-hive/logger/writers/pino';
import { cache } from './cache.ts';
import { useCostLimit } from './cost.ts';
import { createContext, type Context, type ServerContext } from './context.ts';
import { db } from './db/index.ts';
import { createLoaders } from './loaders.ts';
import { schema } from './schema.ts';
const app = Fastify({
logger: true,
logController: new LogController({ disableRequestLogging: true }),
});
/** The operations our clients may send, by their SHA-256 hash. */
const persistedOperations: Record<string, string> = JSON.parse(
readFileSync(new URL('../persisted-operations.json', import.meta.url), 'utf8'),
);
const yoga = createYoga<ServerContext>({
schema,
context: createContext,
plugins: [
usePersistedOperations({
getPersistedOperation: (hash) => persistedOperations[hash] ?? null,
allowArbitraryOperations: process.env.ALLOW_ARBITRARY_OPERATIONS === 'true',
}),
useDepthLimit({ maxDepth: 6 }),
useCostLimit(5_000),
// A subscription keeps its context for every event: give each event fresh loaders.
useExtendContextValuePerExecuteSubscriptionEvent<Context>(() => ({
contextPartial: { loaders: createLoaders(db) },
})),
useResponseCache({
session: () => null,
cache,
ttl: 60_000,
includeExtensionMetadata: process.env.NODE_ENV !== 'production',
}),
],
logging: new Logger({ writers: [new PinoLogWriter(app.log)] }),
});
app.route({
url: yoga.graphqlEndpoint,
method: ['GET', 'POST', 'OPTIONS'],
handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply }),
});
await app.listen({ port: Number(process.env.PORT) });allowArbitraryOperations lets other operations through when ALLOW_ARBITRARY_OPERATIONS is true. Set it in .env, so GraphiQL and curl keep working while you develop. In production it is not set:
PORT=4000
DATABASE_URL=postgres://guild:guild@localhost:5432/guild
LOG_SQL=true
REDIS_URL=redis://localhost:6379
ALLOW_ARBITRARY_OPERATIONS=trueTo see what production does, start the server without it. A variable set in the shell wins over .env: ALLOW_ARBITRARY_OPERATIONS=false pnpm dev. Then send a query, the board by its hash, and a hash that is not on the list:
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guilds { name } }"}'{"errors":[{"message":"PersistedQueryOnly","extensions":{"code":"CANNOT_SEND_PQ_ID_AND_BODY"}}]}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"variables":{"status":"DONE"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"ea91ca7a237e13e307d29bce8b5dfffea83cd5947fb2816a5215fb65401edd22"}}}'{"data":{"quests":[{"id":"1","title":"Slay the cellar rats","reward":20,"status":"DONE","guild":{"name":"Ember Lions"},"assignee":{"name":"Brakka"}},{"id":"6","title":"Calm the storm spirit","reward":300,"status":"DONE","guild":{"name":"Tidecallers"},"assignee":{"name":"Nerys"}}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"0000"}}}'{"errors":[{"message":"PersistedQueryNotFound","extensions":{"code":"PERSISTED_QUERY_NOT_IN_LIST"}}]}
The query is refused, and so is the unknown hash. The board runs with its variables, and mutations work the same way:
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"variables":{"questId":"4","adventurerId":"1"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"bde9c83149a07f4ca734c542eff70efe7895c7017185941b80ad368ce1a0817b"}}}'{"data":{"acceptQuest":{"id":"4","status":"ACCEPTED","assignee":{"name":"Brakka"}}},"extensions":{"responseCache":{"invalidatedEntities":[{"typename":"Quest","id":"4"},{"typename":"Adventurer","id":"1"}]}}}
The mutation selected the assignee, so the cache cleared Adventurer:1 on its own as well. Last, the board as a GET request, which a CDN could cache, with the etag from the last part:
$ curl -si -G localhost:4000/graphql --data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"ea91ca7a237e13e307d29bce8b5dfffea83cd5947fb2816a5215fb65401edd22"}}' --data-urlencode 'variables={"status":"DONE"}' | grep -iE '^(HTTP|etag)'HTTP/1.1 200 OKetag: b80ed0c4ca18e946411ee91bcc31cf0aa1c3d2d42cba881f0c78033d371c8e58
Before you go live
Set
NODE_ENV=production, which takes the cache information out of the responses.Leave out
LOG_SQLandALLOW_ARBITRARY_OPERATIONS. Without the second, introspection and GraphiQL are closed too, because they send operations that are not on the list.Run
pnpm start, notpnpm dev: plain JSON logs, which a log collector can read.In a container, listen on every interface:
app.listen({ port, host: '0.0.0.0' }). Fastify listens onlocalhostby default, which nothing outside the container can reach.Every instance opens up to ten connections to PostgreSQL, which allows 100 by default. With many instances, set
maxon the pool, or put a pooler such as PgBouncer in between.
What you built
This is the project at the end:
guild-api/
├── compose.yml PostgreSQL and Valkey
├── codegen.ts GraphQL Codegen: types for the resolvers
├── drizzle.config.ts drizzle-kit: where the schema and migrations are
├── drizzle/ the migrations
├── operations/ the operations your clients send
├── persisted-operations.json those operations by hash, written by pnpm persist
├── scripts/persist-operations.ts
└── src/
├── server.ts Fastify, Yoga and the plugins
├── schema.graphql the schema
├── schema.ts schema file plus resolvers
├── resolvers.ts queries, mutations, subscriptions, fields
├── context.ts what every resolver gets
├── loaders.ts the DataLoaders
├── pubsub.ts events through Valkey
├── cache.ts the response cache in Valkey
├── cost.ts the cost limit
├── generated/graphql.ts written by pnpm codegen
└── db/ Drizzle tables, connection and seedA GraphQL API with Fastify and Yoga, in TypeScript without a build step, typed from its schema. DataLoader turned 16 queries for the quest board into 3. Subscriptions reach every board over Server-Sent Events, across every instance through Valkey, and survive a Valkey restart. Repeated reads come from a shared cache in a millisecond, and the API turns away whatever is too deep, too expensive, or not from your own clients.
From here, add what your own API needs: authentication in the context, sessions in the cache, and more tables with their loaders. The patterns stay the same.