GraphQL Rate-Limiting by Query Complexity Instead of Request Counting
AI generated
{ }
type
GraphQL · Rate Limiting · Security · Cost Analysis
GraphQL Rate-Limiting by Query Complexity
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.

17 min read Cost Analysis · Depth Limits · Token Bucket Apollo Server · graphql-cost-analysis · Redis

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.

11. FAQ: GraphQL Rate-Limiting by Query Complexity

1Why isn't request counting enough?
A request can generate arbitrary server load through nesting, independent of the number of requests.
2How is complexity calculated?
Base cost per field, multiplied by list sizes, summed across the entire nested structure.
3Checked before or after execution?
Always before, purely by analyzing the query tree against the schema without calling any resolver.
4What HTTP status on exceeding?
Usually still 200, with the error inside the errors array and structured extensions.
5Are depth limits enough alone?
No, flat wide queries can be equally expensive. Always combine with full complexity analysis.
6How to not block admin dashboards?
Differentiated budgets by client type instead of a blanket limit for everyone.
7Can limits be bypassed via aliases?
Not with a correct implementation, every alias instance must count separately toward complexity.
8Count introspection queries too?
Better with their own moderate limit or excluded entirely, otherwise tools like GraphiQL blow the budget.
9Where are budgets stored?
Usually in Redis with atomic increment and expire operations, race-condition-free across instances.
10What is time-based complexity analysis?
An extended cost analysis that measures real resolver runtimes and adjusts costs dynamically.