Stop Abusive GraphQL Queries Before They Run: Cost and Depth Limits in Practice
GraphQL clients control query shape, which means they control server cost. Depth limits and query cost analysis let you reject abusive queries before execution — here's how to implement and tune them.
26 May 2026, 10:36 UTC

Your GraphQL server is one creative client away from a bad afternoon. A single query asking for user { friends { friends { friends { posts { comments { author { friends { ... } } } } } } } } can fan out into millions of resolver calls and database round-trips. REST endpoints have a fixed shape; GraphQL endpoints let the caller decide the shape, which means the caller also decides the cost.
The useful takeaway: you can analyze a query before executing it and reject the expensive ones. Two complementary techniques — depth limiting and query cost analysis — cover most abuse patterns, and they run in microseconds compared to the queries they block.
Depth limiting: the simple guardrail
Depth limiting walks the parsed query document and rejects anything nested beyond a configured level. It is trivial to implement, easy to explain to client teams, and catches the classic recursive-friends attack.
Its blind spot is width. A query with depth 3 that selects 40 list fields, each with first: 1000, is shallow but ruinous. Depth limiting alone gives false confidence, which is why most production setups pair it with cost analysis.
Cost analysis: pricing each field
Query cost (or complexity) analysis assigns every field a numeric price. Leaf scalars typically cost 1. Object fields cost more, and list fields multiply their children's cost by the page-size argument. The server sums the total and rejects queries above a threshold.
The multiplication is the important part. A field like posts(first: 100) wrapping a subtree costing 12 contributes roughly 1200 to the total, which approximates the resolver work of fetching 100 posts and their nested selections.
A worked example with graphql-js
The pattern below uses validation rules — code that inspects the query document before execution. It runs wherever your GraphQL server executes (Node.js process, needs no special permissions) and assumes graphql v16.
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(7),
createComplexityLimitRule(1000, {
onCost: (cost) => console.log('query cost:', cost),
formatErrorMessage: (cost) =>
`Query cost ${cost} exceeds limit 1000. Reduce page sizes or remove nested lists.`,
}),
],
});Then annotate expensive fields in the schema so the estimator knows their weight:
type User {
id: ID!
name: String
friends(first: Int): [User!]! @cost(value: 2, multipliers: ["first"])
posts(first: Int): [Post!]! @cost(value: 5, multipliers: ["first"])
}Exact directive syntax varies by library — graphql-validation-complexity, graphql-query-complexity, and framework-built-in options (Hot Chocolate, Mercurius, graphql-ruby's max_complexity) each compute costs slightly differently. Check your server's documentation for the supported configuration rather than assuming the directive names above transfer.
To verify the setup, send a query you can cost by hand: { user(id: "1") { posts(first: 50) { title } } }. With the annotations above this should cost roughly 1 + 50 × (5 + 1) = 301 and pass. Bump first to 500 and it should be rejected with your formatted error. Confirm both outcomes in a staging environment before deploying the rules.
Trade-offs worth knowing
Cost models are heuristics. If your resolvers use DataLoader-style batching, a query the estimator prices at 900 might execute cheaply, while an unbatched query priced at 300 might hammer the database. The number is a proxy, not a measurement.
Static analysis also can't see runtime behavior: first: 100 on a user with 2 friends costs the same on paper as on a user with 2,000. Some teams add a second layer — per-request timeouts or resolver-level budgets — to catch queries that pass static checks but misbehave at runtime.
Finally, limits reject legitimate clients too. A dashboard that genuinely needs depth 8 will break when you cap at 7. Expect to tune thresholds against real traffic.
Rollout without breaking clients
Start in observe-only mode: compute cost and depth for every request, log the values, but reject nothing. After a week or two of production traffic, set thresholds at a percentile that only clips true outliers — commonly somewhere above the 99th percentile of observed costs. Then enforce, and keep the rejection metric on a dashboard. A sudden spike in rejections after a client release usually means a legitimate new query shape, not an attack, so make the error message actionable and include the computed cost.
The combination — depth cap for recursion, cost cap for amplification, logging for tuning — turns an open-ended execution model into one with a bounded worst case, which is the real prerequisite for sleeping through the night with a public GraphQL endpoint.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.