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:

graphql
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.

How nesting multiplies resolver work in one request One organisation field expands to a hundred repositories, each to a hundred issues, each to a hundred comments, so a single HTTP request becomes about one million resolver invocations while a request counter still records one. One request, four levels, a million resolvers organisation: 1 repositories(first: 100): 100 issues(first: 100): 10,000 comments(first: 100): 1,000,000 resolver invocations request counter records: 1 cost analyser records: 1,010,101 the same document, priced by two different units

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

typescript
// 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:

graphql
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)
}
Where pricing sits in the GraphQL request phases Parsing and validation happen first, then the cost analyser prices the document and charges the bucket, and only then does execution begin, so an over-budget query never reaches a resolver. Charge before the first resolver runs parse syntax only validate schema + depth cap price + charge atomic consume(cost) execute resolvers run over budget: error code RATE_LIMITED, zero resolvers touched pricing after execution protects nothing β€” it only records what already happened Structural cost against measured work for four field types A primary-key lookup and a nested list are priced roughly correctly by structure alone, while an external search field and a fan-out mutation are structurally cheap but expensive to execute, so they need explicit weights. Where structure alone under-prices the work key lookup structure: 1 work: 1 β€” accurate nested list structure: 5,000 work: 5,000 β€” accurate search field structure: 20 work: 1,000 β€” weight it fan-out mutation structure: 1 work: 200 β€” weight it weight the handful of fields where structure and work diverge; leave the rest structural

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. createSubscription is one node and fifty times the work of a field read. Weight mutations explicitly.
  • 200 OK hides the rejection. GraphQL returns errors inside a 200 by default, so clients that check only the HTTP status never learn they were limited. Always set the extensions.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

bash
# 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.