← Back to the library
SecurityApplied · 8 min

One GraphQL request pins the database

“Request rate is flat, but database CPU hits 100% and p99 explodes. The logs show a single POST /graphql with friends { friends { friends ... } } nested ten levels deep, or the same field aliased two hundred times.”

ERROR CODES YOU MAY SEE

A code is a clue. Use its meaning and surrounding evidence to narrow the cause.

COST_ESTIMATED_TOO_EXPENSIVEApollo Router demand control · library

The router's static cost estimate for the operation exceeded the configured maximum, so it was rejected before any subgraph was called.

Log the operation name with its estimated and actual cost. Run demand control in measure mode first so the limit is set from real traffic, not a guess.

Official reference for COST_ESTIMATED_TOO_EXPENSIVE (opens in new tab)Code definition reviewed
429HTTP status codes · RFC 6585 · standard

The client sent too many requests in a given amount of time (rate limiting). The response may include Retry-After.

Read Retry-After and any provider rate-limit headers before retrying. Check whether the limit is per key, per account, or per endpoint, and how many workers share it.

Official reference for 429 (opens in new tab)Code definition reviewed
504HTTP status codes · RFC 9110 · standard

A gateway or proxy did not receive a timely response from the upstream server it needed to complete the request.

Compare the proxy's timeout with the upstream's real latency. The upstream may still finish the work after the client saw 504, so only retry idempotent requests.

Official reference for 504 (opens in new tab)Code definition reviewed

THE PRINCIPLE

Price the operation before you run it. Reject or throttle on estimated cost, not on the number of HTTP requests.

FIRST MOVES

  1. Find the operation: log operation name, a document hash, depth, alias count and resolver time per request; one document usually explains the spike.
  2. Add validation rules for maximum depth (with a lower limit for nested lists), aliases, root fields and batch size. These checks run before any resolver does.
  3. Require pagination on every list field and clamp first/limit on the server. Static analysis can't see runtime list sizes.
  4. Assign field costs, reject operations above a maximum, and rate-limit each client by total cost per window. Run in measure mode first so the limits come from real traffic.
  5. For first-party clients, only execute trusted (persisted) documents registered at build time. Keep server and resolver timeouts as a backstop.

TOOLS: THEN → NOW

Per-route request rate limitsCost-based limits: Apollo Router demand control (@cost, @listSize) or GraphQL Armor cost-limit
Accept any document the client sendsTrusted documents / persisted operations for first-party apps, with depth and alias limits for everything else

PATTERN SNAPSHOT

import { ApolloServer } from "@apollo/server";
import { ApolloArmor } from "@escape.tech/graphql-armor";

// Validation does not know runtime list sizes, so clamp them in resolvers.
const resolvers = { Query: {
  posts: (_: unknown, { first = 20 }: { first?: number }) =>
    db.posts.list({ limit: Math.min(first, 100) }),
} };

const armor = new ApolloArmor({
  maxDepth: { n: 6 },
  maxAliases: { n: 15 },
  costLimit: { maxCost: 5000 },
});
const server = new ApolloServer({ typeDefs, resolvers, ...armor.protect() });

CLOSE THE AI. EXPLAIN THIS.

Why can't per-IP request rate limiting protect a GraphQL endpoint, and what should the limiter count instead?

HOW IT WORKS UNDERNEATH

SOURCES

Guide reviewed