GraphQL Query Cost Rate Limiting
Counting GraphQL requests is meaningless: one document can request three fields or three million, and both arrive as a single POST /graphql. Pricing the document before executing it β the approach the protocol-specific throttling guide generalises β replaces the request count with a number that tracks the work the query will actually cause.
The problem in concrete numbers
A public API exposes organisations, their repositories, and each repositoryβs issues. This document is one request:
query Deep {
organisation(login: "acme") {
repositories(first: 100) {
nodes { issues(first: 100) { nodes { comments(first: 100) { nodes { body } } } } }
}
}
}Read it as work rather than as text: 100 repositories Γ 100 issues Γ 100 comments = one million resolver invocations, from a single HTTP request that a request counter scores as 1. A limit of 5,000 requests per hour permits five billion resolver calls per hour if clients write documents like this, which is why every production GraphQL API eventually adopts cost analysis.
Decision matrix: which cost model
| Model | How cost is derived | Accuracy | Client predictability | Effort |
|---|---|---|---|---|
| Depth limit only | Reject beyond N levels | Blunt β a wide flat query passes | High | Minimal |
| Node count (static) | Fields Γ enclosing page sizes | Good for list-heavy schemas | High: computable client-side | Low |
| Per-field weights | Static count Γ per-field multipliers | Best for mixed read/write cost | High, if the weights are published | Medium |
| Complexity from directives | Schema @cost annotations |
Precise where annotated | High | Medium, ongoing |
| Measured execution time | Charge after the fact | Exact | Poor: unpredictable before sending | High |
| Hybrid: estimate then refund | Charge estimate, refund the difference | Good | Good | High |
Start with static node count plus a depth cap, then layer per-field weights on the handful of fields that are genuinely expensive β a search field hitting an external index, a mutation that fans out to three services. Chasing exactness with post-execution measurement costs more than it saves and leaves clients unable to predict anything.
Step-by-step implementation
// cost-plugin.ts β Apollo Server plugin: price, charge, report.
import { GraphQLError } from "graphql";
import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";
const MAX_SINGLE_COST = 5_000;
const BUDGET_PER_HOUR = 50_000;
export const costPlugin = (consume: (key: string, cost: number) => Promise<Verdict>) => ({
async requestDidStart() {
return {
// Runs after validation, before execution: nothing has hit a resolver yet.
async didResolveOperation({ request, document, schema, contextValue }) {
const cost = getComplexity({
schema, query: document, variables: request.variables,
estimators: [
// Per-field weights declared in the schema take precedence...
fieldExtensionsEstimator(),
// ...otherwise every field costs 1, multiplied by enclosing list sizes.
simpleEstimator({ defaultComplexity: 1 }),
],
});
if (cost > MAX_SINGLE_COST) {
throw new GraphQLError(
`Query cost ${cost} exceeds the ${MAX_SINGLE_COST} maximum for a single operation.`,
{ extensions: { code: "QUERY_TOO_EXPENSIVE", cost, max: MAX_SINGLE_COST } },
);
}
const v = await consume(`gql:${contextValue.apiKey}`, cost);
contextValue.rateLimit = { cost, remaining: v.remaining, resetIn: v.resetSeconds };
if (!v.allowed) {
throw new GraphQLError("Rate limit exceeded", {
extensions: {
code: "RATE_LIMITED", cost, remaining: 0,
retryAfter: v.retryAfterSeconds, budget: BUDGET_PER_HOUR,
},
});
}
},
// Always report the balance, so clients can self-pace instead of probing.
async willSendResponse({ contextValue, response }) {
const rl = contextValue.rateLimit;
if (!rl || response.body.kind !== "single") return;
response.body.singleResult.extensions = {
...response.body.singleResult.extensions,
rateLimit: { cost: rl.cost, remaining: rl.remaining, resetIn: rl.resetIn },
};
response.http.headers.set("X-RateLimit-Limit", String(BUDGET_PER_HOUR));
response.http.headers.set("X-RateLimit-Remaining", String(rl.remaining));
response.http.headers.set("X-RateLimit-Reset", String(rl.resetIn));
},
};
},
});Declaring weights in the schema keeps the price next to the field it prices, which is the only way the two stay in sync:
type Query {
# A cheap primary-key lookup.
repository(owner: String!, name: String!): Repository @cost(complexity: 1)
# An external search index: expensive regardless of page size.
search(query: String!, first: Int = 20): SearchResults
@cost(complexity: 50, multipliers: ["first"])
}
type Mutation {
# Fans out to billing, email, and audit services.
createSubscription(input: SubscriptionInput!): Subscription @cost(complexity: 200)
}Gotchas and edge cases
- Unbounded lists default to something. A list field with no pagination argument must be priced with an assumed page size, or an attacker simply omits
first. Better: make pagination arguments required on any list that can exceed a page. - Fragments and aliases multiply. The same expensive field aliased ten times is ten field selections; a cost analyser that deduplicates by field name under-prices it badly. Count selections, not names.
- Introspection is not free. A full introspection query on a large schema is genuinely expensive. Price it, cache the result, or disable introspection in production.
- Mutations priced structurally are wrong.
createSubscriptionis one node and fifty times the work of a field read. Weight mutations explicitly. 200 OKhides the rejection. GraphQL returns errors inside a200by default, so clients that check only the HTTP status never learn they were limited. Always set theextensions.code, and mirror the state into headers.- Batched operations bypass a per-request charge. An array of ten documents in one HTTP request must be priced as the sum of ten, not as one.
- Persisted queries change the calculus. If only allow-listed documents can be executed, their costs are known in advance β precompute and cache the price by document hash instead of analysing on every request.
Verification and testing
# 1. A deliberately expensive document must be refused before execution.
curl -s https://api.example.com/graphql -H "Content-Type: application/json" \
-H "X-API-Key: gql_test" -d '{"query":"{ organisation(login:\"acme\"){ repositories(first:100){ nodes{ issues(first:100){ nodes{ id } } } } } }"}' \
| jq '.errors[0].extensions'
# { "code": "QUERY_TOO_EXPENSIVE", "cost": 10101, "max": 5000 }
# 2. Every successful response reports the balance.
curl -s https://api.example.com/graphql -H "Content-Type: application/json" \
-H "X-API-Key: gql_test" -d '{"query":"{ repository(owner:\"acme\", name:\"api\"){ name } }"}' \
| jq '.extensions.rateLimit'
# { "cost": 2, "remaining": 49998, "resetIn": 3512 }Add a unit test asserting the price of a fixed set of representative documents. When someone adds a list field without a page-size multiplier, that test changes, which is the earliest possible warning that your model has drifted from your schema.
Frequently Asked Questions
Static analysis or measured execution cost?
Static, for anything client-facing. It is computable before execution, so it can prevent work rather than record it, and clients can predict the price of a document before sending it. Measured cost is useful as a calibration signal β compare priced cost with observed execution time periodically and adjust weights β not as the enforcement mechanism.
Should a rate-limited GraphQL response use HTTP 429?
Return a machine-readable extensions.code of RATE_LIMITED in all cases, and set 429 on the transport where your framework allows it. Many clients and proxies only inspect the status code, and many GraphQL clients only inspect the errors array, so serving both covers each audience.
How do I choose the budget number?
Price the queries your real clients already send, take the 95th percentile of cost per useful interaction, and multiply by the number of interactions per hour you consider reasonable for a plan. Publishing a budget derived from measured client behaviour produces far fewer complaints than a round number chosen in advance.
What about subscriptions?
Price them on two axes: the cost of establishing the subscription, and an ongoing charge per delivered event. A subscription that is cheap to open but pushes thousands of events per minute is exactly the pattern a per-operation charge misses.
Related
- Protocol-Specific Throttling β the parent guide on cost functions across protocols.
- WebSocket Message Rate Limiting β the connection-scoped counterpart for subscriptions.
- Token Bucket Implementation β the bucket the computed cost is charged against.
- Rate-Limit Response Headers β mirroring the budget into transport headers.