why plain request counting is not enough
A single GraphQL request can combine nested fields, multiple aliases and deep relations, generating as much database load as a thousand simple REST requests. Anyone who implements rate-limiting purely by counting requests per minute does not protect their API against exactly this kind of attack. Query complexity analysis scores every request by its actual cost before it is even executed.
Table of Contents
- 1. Why request counting fails for GraphQL
- 2. What query complexity actually measures
- 3. Defining complexity values in the schema
- 4. Static analysis before execution
- 5. Complexity budgets per client and time window
- 6. Depth limits as a complementary layer
- 7. Error responses and client communication
- 8. Common pitfalls when rolling it out
- 9. Rate-limiting strategies compared
- 10. Summary
- 11. FAQ
1. Why request counting fails for GraphQL
Classic rate-limiting for REST APIs counts requests per time window, say a hundred requests per minute per API key. This assumption works because a single REST endpoint roughly always triggers the same amount of work. GraphQL rate-limiting on the same principle fails, however, because a single GraphQL request can describe an arbitrarily complex tree of nested fields. A client can query a thousand products with their reviews, variants and related products in a single request, which triggers hundreds of database queries server-side.
This makes request counting a deceptive form of protection: an attacker formally stays within the limit of a hundred requests per minute, yet through cleverly nested queries produces a load that would have required a thousand flat REST calls. Without GraphQL rate-limiting based on actual query cost, the API remains unprotected against exactly this form of denial-of-service attack, even if a classic request limit is configured correctly.
2. What query complexity actually measures
Query complexity is a numeric score calculated before execution that estimates how expensive a request would be for the server. The basic idea behind GraphQL rate-limiting by complexity: every field in the schema gets a base cost value assigned, usually 1 for a simple scalar field. Fields that return lists multiply their cost by the expected number of elements, typically via a first or limit argument. Nested fields sum up, so a deeply nested query becomes exponentially more expensive than a flat one.
The decisive advantage over request counting: the complexity calculation happens before execution, purely by analyzing the query tree against the schema. The server does not need to run a single database query to detect and reject an overpriced query. That means GraphQL rate-limiting by complexity protects not only against intentional attacks, but also against accidentally inefficient queries from a poorly optimized frontend.
3. Defining complexity values in the schema
In practice, complexity values are attached to fields in the schema via directives or configuration objects. By default, most libraries assign a base value of 1 per field, but expensive operations such as full-text search, aggregations or computations over large datasets should be scored explicitly higher. A field like products(search: String): [Product] that triggers an Elasticsearch query realistically deserves a much higher base value than a plain product(id: ID!): Product.
This fine-tuning is the part of GraphQL rate-limiting that requires the most domain knowledge. A blanket value of 1 per field ignores that some resolvers need a single Redis cache hit, while others trigger an expensive aggregation over millions of rows. Teams that set complexity values once and never revisit them risk the actual resolver costs drifting away from the stored estimates over time, for example when a field later gains an expensive external API call.
# Schema with explicit complexity weighting via directive
directive @cost(value: Int!, multipliers: [String!]) on FIELD_DEFINITION
type Query {
product(id: ID!): Product @cost(value: 1)
products(first: Int = 20, search: String): [Product!]!
@cost(value: 5, multipliers: ["first"])
productRecommendations(productId: ID!, limit: Int = 10): [Product!]!
@cost(value: 20, multipliers: ["limit"])
}
type Product {
id: ID!
name: String!
# Reviews trigger a join, weighted higher than a plain scalar
reviews(first: Int = 10): [Review!]! @cost(value: 3, multipliers: ["first"])
relatedProducts(first: Int = 5): [Product!]! @cost(value: 8, multipliers: ["first"])
}
4. Static analysis before execution
The technical implementation of GraphQL rate-limiting by complexity runs as its own validation phase, before the query executor calls a single resolver. Libraries like graphql-cost-analysis for Apollo Server or the built-in complexity plugin in GraphQL Yoga walk the parsed query AST, sum up the configured costs per field and factor in arguments like first or limit as multipliers. If the total exceeds a configured threshold, the request is rejected before a single resolver has run.
It matters to correctly include variables in the calculation: an attacker could try to pass the limit argument via a variable instead of a literal to evade static code analysis. Mature GraphQL rate-limiting implementations resolve variables before the complexity calculation and use conservative maximum values when variable values are missing, instead of simply skipping the multiplication.
// server.js — enforcing complexity-based rate limiting in Apollo Server
import { costAnalysis } from 'graphql-cost-analysis';
import { ApolloServer } from '@apollo/server';
const MAX_COMPLEXITY = 1000;
const server = new ApolloServer({
schema,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request, document, contextValue }) {
const complexity = costAnalysis({
document,
schema,
variables: request.variables ?? {},
// Conservative default when a client omits a limit argument
defaultCost: 1,
maximumCost: MAX_COMPLEXITY,
});
if (complexity > MAX_COMPLEXITY) {
throw new GraphQLError('Query too complex', {
extensions: {
code: 'QUERY_TOO_COMPLEX',
complexity,
maxComplexity: MAX_COMPLEXITY,
},
});
}
// Track remaining budget for this client in Redis
await contextValue.rateLimiter.consume(contextValue.clientId, complexity);
},
};
},
},
],
});
5. Complexity budgets per client and time window
A one-off check per query is not enough, since a client could send many medium-sized queries instead of a single huge one and still generate the same overall load. Effective GraphQL rate-limiting therefore combines the per-query complexity score with a budget per client over a rolling time window, similar to a token bucket algorithm. Every client gets a quota of complexity points per minute, which is drawn down with each request and continuously refills.
Redis is well suited as a central store for these budgets, since atomic increment operations via INCRBY and expiry via EXPIRE work race-condition-free even when multiple server instances process requests concurrently. A client that exhausts its budget receives a clear error stating when the budget refills, instead of just arbitrarily rejecting requests. This combination of query complexity and time-window budget is the actual core of production GraphQL rate-limiting.
6. Depth limits as a complementary layer
Alongside complexity scores, a simple depth limit is a useful addition that already catches many attack vectors before the actual cost calculation. Circular relationships in the schema, such as Product.relatedProducts.relatedProducts.relatedProducts, theoretically allow unbounded nesting depth. A hard limit of roughly seven to ten nesting levels prevents such pathological queries independently of the complexity calculation and is considerably simpler to implement than full cost analysis.
Depth limits, however, do not replace full GraphQL rate-limiting by complexity, since a flat query with many wide lists can be just as expensive as a deep one. The most robust architecture combines both protection layers: depth limits as a fast, cheap pre-check right after parsing, complexity analysis as a more precise second layer that accounts for actual costs including list multipliers.
# Example: rejecting a query before execution based on combined limits
curl -X POST https://api.mironsoft.de/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"query": "{ products(first: 100) { relatedProducts(first: 50) { relatedProducts(first: 50) { name } } } } "}'
# Response when complexity budget is exceeded
# HTTP/1.1 200 OK (GraphQL errors are returned with 200, not 429)
# {
# "errors": [{
# "message": "Query too complex",
# "extensions": { "code": "QUERY_TOO_COMPLEX", "complexity": 2500, "maxComplexity": 1000 }
# }]
# }
7. Error responses and client communication
Unlike REST, where an exceeded limit is classically signaled with HTTP status 429 plus a Retry-After header, most GraphQL servers respond with HTTP 200 regardless, even on error, since partial results can coexist with errors. GraphQL rate-limiting errors therefore end up in the response's errors array, with a structured extensions object containing the code, current complexity and limit, so client libraries can react programmatically instead of just displaying a generic error message.
For a good client experience, it pays off to proactively surface the remaining budget, for example through a custom header like X-RateLimit-Remaining-Complexity. That way a frontend team can warn ahead of the actual limit or simplify a request client-side, for instance by reducing the requested list size, instead of only reacting after a hard failure. Good documentation of per-field complexity costs is also part of a cleanly communicated GraphQL rate-limiting system.
{
"errors": [
{
"message": "Query too complex",
"extensions": {
"code": "QUERY_TOO_COMPLEX",
"complexity": 2500,
"maxComplexity": 1000
}
}
],
"extensions": {
"rateLimit": {
"remainingComplexity": 0,
"resetAt": "2026-08-06T14:05:00Z"
}
}
}
8. Common pitfalls when rolling it out
The most common mistake when introducing GraphQL rate-limiting by complexity is setting the limit too low, blocking legitimate but data-intensive dashboards, for example admin interfaces that deliberately load many nested fields at once. The fix is rarely a blanket higher limit for everyone, but differentiated budgets by client type: internal admin tools get a higher quota than public storefront clients.
A second mistake is ignoring fragment reuse: if the same fragment is included multiple times in a query via aliases, the complexity calculation must count each instance separately, otherwise the limit can be deliberately bypassed through repeated aliases. A third, subtler mistake concerns introspection queries: these should be excluded from the complexity calculation or given their own moderate limit, since tools like GraphiQL can otherwise blow the regular budget with a plain schema introspection query.
# Alias abuse — without per-instance counting this could bypass the limit
query AliasAbuse {
p1: products(first: 50) { relatedProducts(first: 50) { name } }
p2: products(first: 50) { relatedProducts(first: 50) { name } }
p3: products(first: 50) { relatedProducts(first: 50) { name } }
# Correct cost analysis multiplies cost by the number of alias instances,
# so this query costs 3x a single products(first: 50) call, not 1x
}
9. Rate-limiting strategies compared
The overview below compares the common approaches to GraphQL rate-limiting by protective effect, implementation effort and side effects for legitimate clients.
| Strategy | Protection against expensive queries | Implementation effort | Risk for legitimate clients |
|---|---|---|---|
| Request counting | Low | Very low | Low |
| Depth limits | Medium | Low | Medium |
| Query complexity analysis | High | Medium to high | Low (with good calibration) |
| Time-based complexity analysis | Very high | High | Low |
Time-based complexity analysis additionally measures actual resolver runtimes and automatically adjusts cost values to real-world latency, instead of relying on statically estimated values. For most teams, a well-calibrated static query complexity analysis combined with depth limits is the best trade-off between protective effect and maintenance effort.
Mironsoft
GraphQL security, API architecture and Magento integrations
Is your GraphQL API actually protected against expensive queries?
We analyze your schema, define realistic complexity values per field and set up rate-limiting with Redis budgets that does not slow down legitimate clients.
Schema audit
Identifying expensive fields and realistic complexity costs per resolver
Limiting setup
Setting up cost analysis, depth limits and Redis-based budgets in production
Client communication
Structured error responses and budget headers for a clean API experience
10. Summary
GraphQL rate-limiting by query complexity instead of plain request counting is not an optimization, it is a necessity as soon as an API is reachable in production. Because GraphQL allows describing arbitrarily nested and arbitrarily wide data structures in a single request, the number of requests is not a reliable indicator of actual server load. Cost analysis scores every query before execution by its real cost and rejects overpriced requests before a single resolver runs.
The most robust implementation combines static complexity values in the schema, depth limits as a fast pre-check and a client budget over a rolling time window in Redis. Anyone who calibrates these building blocks carefully and communicates error responses in a structured way protects their GraphQL API against denial-of-service attacks and accidentally inefficient frontend queries alike, without unnecessarily slowing down legitimate users.
GraphQL Rate-Limiting by Query Complexity — Key Takeaways
Request counting is not enough
A single nested query can generate the database load of a thousand REST calls without exceeding the request limit.
Cost analysis before execution
Complexity is calculated from the query AST before any resolver runs. Overpriced queries are rejected immediately.
Depth limits as a pre-check
A hard nesting limit catches pathological queries cheaply, before the more precise cost analysis runs.
Budgets per client in Redis
Rolling time-window budgets prevent many medium-sized queries from generating the same load as one large one.