Rate Limiting and Query Complexity in Federated GraphQL

Part of Advanced GraphQL Federation Patterns, this guide explains why request-counting rate limits fail in federated GraphQL because one client operation fans out across many subgraphs — content, media, localization, commerce — and the computational cost compounds before any payload returns. You need two independent controls: deterministic complexity scoring at the router, and frequency-based rate limiting keyed per tenant. Without them, agency and Jamstack teams hit unpredictable TTFB spikes, falling cache hit ratios, and cascading gateway timeouts. Both are decided as part of Headless CMS Architecture & Platform Selection, where gateway configuration sets the performance ceiling and tenant isolation boundary.

The execution-cost problem

Unbounded complexity comes from resolver-depth multiplication plus uncoordinated pagination defaults. A single operation can trigger parallel execution across dozens of subgraphs; if each applies a default first: 50 or limit: 100, the aggregated result blows past payload thresholds. That shows up as router memory pressure, serialization latency, and eventual 504s.

Diagnosing it means tracing the execution plan before the query reaches CMS data stores. REST rate limiters measure HTTP request frequency, not computational weight — a lightweight introspection query might cost 5 units while a deeply nested content tree with unbounded lists costs 15,000. Treat them identically at the network layer and resource exhaustion is guaranteed.

Complexity scores of four real operationsCost scores for four operations sent to the same federated CMS graph, showing how nesting and list sizes multiply cost while request counts treat them the same.Navigation menu12 costArticle page with author45 costListing, 50 items + authors620 costNested categories x articles x tags15400 costScored with list multipliers of first/limit and a base cost of 1 per field.
All four are one HTTP request; their execution costs differ by three orders of magnitude.

The two controls act as independent gates an operation must clear before subgraphs execute:

Two independent gates before executionAn incoming operation from a build token skips the rate limit but not complexity scoring; other operations must be within the tenant's rate limit, then under the complexity threshold, before the router executes the federated plan.Incoming operationBuildtoken?Tenant ratelimit ok?429Cost underthreshold?COMPLEXITY_LIMIT_EXCEEDEDExecute planyesnonoyesnoyes
Frequency and cost are separate questions; build traffic is exempt from the first, never from the second.

Deterministic complexity scoring

Scoring starts at the routing layer: parse the AST, assign static and dynamic weights to field selections, and reject queries over a threshold before execution. Validation plugins evaluate field depth, connection multipliers, and custom scalar costs. The config below attaches cost multipliers to pagination arguments so the router estimates worst-case paths.

TypeScript
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import { GraphQLError } from 'graphql';

const complexityRule = createComplexityLimitRule(1000, {
  onCost: (cost) => console.warn(`Query complexity score: ${cost}`),
  createError: (max, actual) => new GraphQLError(
    `Query complexity ${actual} exceeds maximum allowed ${max}`,
    { extensions: { code: 'COMPLEXITY_LIMIT_EXCEEDED' } }
  ),
  fieldExtensions: {
    cost: (args) => args?.limit || args?.first || 10,
    multipliers: ['limit', 'first', 'last', 'pageSize']
  }
});

export const validationRules = [complexityRule];

This rejects expensive resolver trees before they materialize. The standardized error code lets frontends fall back gracefully or prompt editors to simplify a query. For gateway-level policy patterns, see Advanced GraphQL Federation Patterns.

Distributed rate limiting

Complexity analysis stops expensive queries; it does nothing against credential stuffing, rapid polling, or scraping. Rate limiting handles frequency per tenant, API key, or IP. In multi-tenant deployments, attach sliding-window counters to the gateway router, not individual subgraphs — centralizing the state avoids inconsistencies and keeps throttling accurate across boundaries.

YAML
# Apollo Router configuration for distributed rate limiting
rate_limit:
  - name: tenant_throttle
    source: header
    header_name: x-tenant-id
    algorithm: sliding_window
    window_size: 60s
    max_requests: 300
    redis:
      url: redis://cache.internal:6379
      key_prefix: "cms:ratelimit:"

When a tenant exceeds quota, return 429 Too Many Requests per RFC 6585, with structured JSON so frontend SDKs can back off exponentially without manual handling.

A sliding window absorbing a burstA tenant sends a burst of 250 requests in ten seconds within a 60-second window of 300 requests; the window admits the burst and throttles further requests until older requests slide out of the window.Burst: 250 requestsadmittedSteady traffic50 requests, admittedThrottled (429)window fullCapacity returnsburst ages out0 s20 s40 s60 s80 s
Sliding windows allow short bursts up to the quota and recover smoothly as old requests age out.

Build-time exemptions

Static site generation breaks naive limits: Jamstack builds fire hundreds of parallel GraphQL queries in CI, tripping false-positive rate limits and exhausting tenant quotas. Run a dual-tier policy — one for runtime client traffic, one for machine-to-machine build processes. Build tokens bypass the sliding window but still honor complexity caps so a runaway query can’t slip through. Add a cache-warming layer that materializes frequent queries into CDN edge nodes to cut origin load; paired with ISR, content updates propagate without overloading the gateway.

Monitoring and governance

Governance needs observability into both complexity scores and throttle events. Track avg_query_cost, throttle_rate, and cache_miss_ratio alongside standard DX metrics. Log rejected queries with their AST signatures so you can find high-cost resolvers, add DataLoader batching, or refactor unbounded lists into cursor-based pagination. That turns rate limiting from a defensive patch into an architecture control aligned with compliance boundaries.

Scoring With Directives in the Schema

Hard-coding field costs in router configuration drifts from the schema as it evolves. A more maintainable approach declares costs in the subgraph schemas themselves, with a @cost directive for expensive fields and a @listSize directive that tells the scorer which argument sizes a list, so the owners of each subgraph decide what their fields cost. The router reads the composed directives and computes the score for every operation. Federation-aware routers and several validation libraries support this style; where yours does not, generate the router’s cost map from the schema in CI so the two cannot drift.

GraphQL
# editorial subgraph (excerpt)
type Query {
  articles(first: Int = 10, after: String): ArticleConnection! @listSize(slicingArguments: ["first"])
  search(term: String!): [SearchHit!]! @cost(weight: 50)   # hits a separate search index
}

type Article @key(fields: "id") {
  id: ID!
  body: RichText @cost(weight: 5)                           # resolves embedded entries
  related(first: Int = 5): [Article!]! @listSize(slicingArguments: ["first"])
}

With costs in the schema, a change that makes a field more expensive, such as adding embedded entry resolution to body, shows up as a cost change in schema review, and the effect on existing operations can be computed before deploy by scoring recorded operations against the new schema.

Configuration Reference

Setting Starting value Why
Complexity limit 1,000 cost units Above your most expensive legitimate operation, measured from logs.
List multiplier first / limit argument, default 10 Worst-case estimate of list sizes.
Depth limit 8 Blocks recursive traversal before scoring.
Rate limit 300 requests per 60 s per tenant Sized from peak traffic plus headroom.
Build token policy exempt from rate limit, subject to complexity CI builds are bursty but must stay bounded.

Gotchas & Edge Cases

  • Configuration syntax differs by router. The YAML above sketches the policy; the exact keys depend on your router and version. The Apollo Router’s built-in traffic shaping limits per instance, and distributed, per-tenant limits typically need a coprocessor, an enterprise feature or an API gateway in front that shares state in Redis.
  • Costs without list arguments. A list field without first or limit gets the default multiplier, which can badly underestimate unbounded lists. Require pagination arguments on large lists, or give such fields a high static cost.
  • Persisted queries and cost. With trusted documents, costs can be computed once at registration time, so the router only checks a stored score per operation id.
  • Retry storms after 429. Clients that retry immediately make throttling worse. Send Retry-After and make SDKs back off with jitter.

Worked Example

An agency platform served thirty client sites from one federated CMS graph. One client’s static build, which requested every page with deeply nested related-content blocks, regularly consumed most of the shared CMS quota and slowed every other site. Scoring showed that its listing operation cost over 15,000 units. The team introduced a complexity limit of 2,000, per-tenant sliding windows, and build tokens exempt from frequency limits but not from cost; the client’s build had to paginate its related content, and the other twenty-nine sites’ p95 latency dropped by more than half during that client’s builds.

Rollout Checklist

  • Log the computed cost of every operation in production for a week before enforcing any limit.
  • Set the complexity limit above the most expensive legitimate operation and review exceptions.
  • Add per-tenant sliding windows at the router or the gateway in front of it, backed by shared state.
  • Issue build tokens that bypass frequency limits but never cost limits.
  • Return structured errors and Retry-After, and teach client SDKs to back off with jitter.

Cost limits and persisted queries reinforce each other rather than compete with each other in practice. With persisted queries, every public operation is known and scored at build time, and runtime scoring remains only for internal or partner clients that may send ad-hoc queries.

Frequently Asked Questions

Should complexity be scored at the router or in each subgraph?

At the router, where the whole operation is visible before any subgraph work starts. Subgraphs can still enforce their own limits as defence in depth, especially when they are reachable directly.

How do I choose the complexity threshold?

Log the computed cost of every operation for a week, find the most expensive legitimate one, and set the limit comfortably above it. Revisit when new features add expensive operations.

Do rate limits make sense with a CDN in front?

Yes, for requests that miss the cache, which are exactly the ones that reach subgraphs. CDN hits never touch the router and should not count against tenant limits.

Should editors’ preview traffic count against tenant limits?

Give preview its own, smaller quota. Preview requests are uncached and can be bursty during live editing; separating them keeps a busy editing session from throttling public traffic, and vice versa.