API security depends on more than just basic rate limiting. Learn to prevent resource exhaustion by calculating query complexity before execution.
Last month, we had a production incident where a single client query brought our database CPU to 98% utilization for ten minutes. The request wasn't technically a "denial of service" attack—it was a perfectly valid, authorized request—but its recursive nature forced an exponential join that our indexer couldn't handle.
Standard API rate limiting with token bucket algorithms wasn't enough because we were counting requests, not the cost of those requests. To fix this, we implemented request-level query complexity analysis to kill expensive operations before they ever hit the database.
If you're building a GraphQL API or a REST API with embedded resource expansion (like ?include=users.posts.comments.likes), you’re vulnerable to "query depth" attacks. A user can send a request that looks small but forces the server to traverse thousands of nodes.
We first tried to solve this by simply limiting the maximum depth of our object graph to 5 levels. It was a naive approach. A shallow query fetching 10,000 items is often more expensive than a deep query fetching 5 items. We needed a system that assigned a "cost" to each field based on its computational weight.
The goal is to calculate a total cost score for a request before you hit your business logic. We assign a base cost of 1 to every field, but we add multipliers for collections.
Here is a simplified schema-based cost map:
JAVASCRIPTconst costMap = { user: { cost: 1 }, posts: { cost: 2, multiplier: 10 }, // Each post fetched counts as 10 comments: { cost: 5, multiplier: 50 }, };
When a request comes in, we traverse the AST (Abstract Syntax Tree) and sum the costs. If the total exceeds a threshold—say, 500 units—we reject the request with a 429 Too Many Requests status code.
You don't need a complex parser if you're using a standard library. In our Node.js environment, we use the graphql-validation-complexity package (or a custom visitor pattern for REST) to generate the score.
JAVASCRIPTfunction calculateComplexity(node, depth = 0) { let cost = node.cost || 1; if (node.children) { for (const child of node.children) { cost += (child.multiplier || 1) * calculateComplexity(child, depth + 1); } } return cost; }
This approach allows us to be generous with simple lookups while being extremely restrictive with "deep" data fetching. It’s effectively a dynamic budget for every individual request.
This isn't a replacement for API throttling; it's an enhancement. You still need to manage your overall throughput, but complexity analysis ensures that the requests you do allow are safe to execute.
When implementing this, keep these three things in mind:
We found that static analysis doesn't account for database lock contention. A query might have a low "complexity score" but still be slow if it hits a locked row. We've considered adding a dynamic component—where the cost is adjusted based on current database load—but that introduces significant latency to the request validation phase.
For now, we stick to static estimation. It’s predictable, fast (usually adds around 2–5ms to request processing), and it has successfully prevented the kind of cascading failures we saw last quarter.
Ultimately, preventing resource exhaustion is about visibility. Once you start measuring the cost of your queries, you stop treating every request as equal. You’ll find that a small subset of your traffic is responsible for the vast majority of your load. By capping that, you keep your system stable for everyone else.
I’m still experimenting with how to communicate these limits to our frontend developers. Right now, they only see a 429 error, which is frustrating. Ideally, we’d expose the cost as a header in the response, allowing them to optimize their queries before they hit the limit. We’re not there yet, but it’s the logical next step in our reliability engineering journey.
API security shouldn't be scattered across your controllers. Learn to decouple field-level authorization using metadata to maintain clean, scalable code.
Read moreREST API field selection allows clients to request only the data they need. Learn to implement GraphQL-style patterns to stop data over-fetching today.