and Limited in Practice
A GraphQL API without a complexity limit is an open door for overloading attacks. Depth limits, field-specific costs and complexity budgets are the three layers that stop a single query from occupying the server for seconds, or for minutes, if the schema has enough expensive fields.
Table of Contents
- 1. Why query complexity is a security topic, not a performance topic
- 2. How complexity is calculated
- 3. Depth limits: simple, but limited in effectiveness
- 4. Field-specific costs: weighting expensive fields correctly
- 5. Implementation: calculating complexity before execution
- 6. Common mistakes when using complexity limits
- 7. Complexity limits in Magento GraphQL
- 8. Low versus high complexity compared
- 9. Summary
- 10. At a glance
- 11. FAQ
1. Why query complexity is a security topic, not a performance topic
Query complexity is often discussed as a pure performance optimization topic, but the actual starting point is security. GraphQL APIs are flexible by nature: clients can formulate arbitrary queries and request any number of fields at any depth. That is the core of the GraphQL promise. But this flexibility has a downside: a client, or an attacker, can craft a query that occupies the server for seconds or minutes without expending much effort themselves.
A depth limit alone is not enough here. A flat query with a hundred nested list fields can be just as expensive as a deep query. The keyword is query complexity: a numeric value that estimates the cost of a query before it executes. If this value exceeds a configured budget, the query is rejected before a single resolver is called. This is the crucial difference from rate limiting: complexity limits do not check how many requests a client sends, but how expensive each individual query is.
2. How complexity is calculated
The basic idea behind complexity calculation is simple: every field in the schema has an assigned cost. Simple scalar fields cost 1. Fields that return a list cost more, typically weighted by the multiplier of the contained elements. Fields that trigger expensive database operations get a higher base value. The total complexity of a query is the sum of all field costs, where nested fields are multiplied by the cost of their parent field.
There are different calculation models: the simplest counts the number of fields queried. The most sensible one weights each field individually and accounts for the multiplier introduced by list fields. The most elaborate one also considers arguments: pageSize: 100 costs more than pageSize: 5, because the result is proportionally larger. For most production APIs, the second model is sufficient: it reliably identifies expensive queries without requiring a separate cost formula for every argument.
# Complexity analysis example (costs annotated as comments)
query ExpensiveQuery {
products(search: "jacket", pageSize: 50) { # cost: 10 (list field, multiplier x50)
items { # cost: 1 per item = 50
sku # cost: 1 per item = 50
name # cost: 1 per item = 50
related_products { # cost: 5 per item (nested list) = 250
sku # cost: 1 per related product = 250
name # cost: 1 per related product = 250
media_gallery { # cost: 3 per item = 750
url # cost: 1 = 750
}
}
}
}
}
# Total estimated complexity: ~2410, exceeds limit of 200, query rejected
# Lean query, low complexity
query LeanQuery {
products(search: "jacket", pageSize: 10) { # cost: 10
items { # cost: 10
sku # cost: 10
name # cost: 10
url_key # cost: 10
}
}
}
# Total estimated complexity: ~50, well within limit
3. Depth limits: simple, but limited in effectiveness
The depth limit is the simplest form of query restriction and, at the same time, the least effective when used on its own. It limits how deeply a query may be nested. A limit of 10 means: a maximum of 10 levels from the root of the query to the deepest field. This prevents the classic circular query (Product → related_products → related_products → ...), which without a limit recurses infinitely deep and blocks the server.
The problem with depth limits: they do not detect wide queries. A query with only three levels of nesting that queries 50 fields at each level, where each field triggers an expensive database join, is significantly more expensive than a deep but narrow query. Depth limits should therefore always be configured together with complexity limits, with the depth limit acting as a simple first barrier and the complexity limit as a smarter second layer that estimates the actual cost.
4. Field-specific costs: weighting expensive fields correctly
The most valuable step in complexity configuration is assigning field-specific costs. Not every field costs the same: a simple name field that is read directly from memory is trivial. A related_products field that triggers a separate database join and possibly fires N further resolvers is orders of magnitude more expensive. If every field carries the same base value, the complexity system simultaneously overestimates simple queries and underestimates expensive queries, an inaccurate model that leads either to incorrect throttling or to too little protection.
Field costs are assigned in the schema definition through directives (@complexity) or through external configuration files, depending on the framework used. In Magento, the complexity logic is implemented via validation rules in the GraphQL infrastructure. For custom fields wired in through @resolver, the complexity cost can be influenced through the resolver implementation, though these costs still need to be picked up by the complexity validation logic.
# Schema with complexity costs assigned per field
# Using @complexity directive (supported by graphql-php and similar libraries)
type Query {
# Simple list, moderate cost
products(search: String, pageSize: Int): ProductOutput
@complexity(value: 10, multipliers: ["pageSize"])
# Expensive join, higher base cost
categoryList(filters: CategoryFilterInput): [CategoryTree]
@complexity(value: 20)
}
type ProductInterface {
sku: String # cost: 1 (scalar, cheap)
name: String # cost: 1 (scalar, cheap)
# Nested list, expensive, triggers separate DB query per product
related_products: [ProductInterface]
@complexity(value: 5, multipliers: ["pageSize"])
# External data source, highest cost
stock_status: ProductStockStatus
@complexity(value: 3)
}
5. Implementation: calculating complexity before execution
Complexity checking must happen before query execution. A complexity check that only aborts during execution has already partially caused the problem: resolvers were called, database connections were opened, memory was reserved. The correct position is the validation phase: after the query is parsed but before the first resolver call, complexity is calculated and compared against the configured budget. If complexity exceeds the budget, an error is returned and execution never begins.
The error response for an exceeded complexity limit should be consistent and not too informative: the exact complexity value should not be returned to the client, because that helps attackers optimize their query step by step up to the limit. A generic message like "Query exceeds maximum allowed complexity" is sufficient. In the server log, on the other hand, the actual complexity value should be recorded for debugging and monitoring, so legitimate clients that unintentionally exceed the limit can trace their mistake.
6. Common mistakes when using complexity limits
The most common mistake is a complexity budget that is configured too low and rejects legitimate frontend queries. That leads teams to quickly raise the limit, sometimes up to a value that no longer offers any meaningful protection. The correct approach is to first measure the actual complexity values of production queries and then set the budget with a reasonable buffer above them. Tools like GraphQL Hive or Apollo Studio can aggregate the complexity distribution across all production queries.
A second common mistake is failing to distinguish between authenticated and anonymous clients. A logged-in admin user has different requirements than an anonymous shop visitor. It makes sense to configure different complexity budgets for different roles: anonymous clients get a strict limit, authenticated clients a higher one, internal service accounts possibly none at all. This requires a middleware layer that derives the budget dynamically from the auth context.
7. Complexity limits in Magento GraphQL
Magento GraphQL has its own complexity validation, read from configuration. The default values are configurable in the Magento configuration under graphql/validation/complexity_limit and graphql/validation/depth_limit. In production mode, default limits are active; in developer mode, they are often disabled or set very high so as not to hinder development. A common mistake: developer-mode configuration is accidentally carried over to the staging environment, and nobody notices the limits are missing.
Magento calculates query complexity before execution using a built-in algorithm. For custom fields and resolvers wired into the schema, their complexity is calculated by default using a base value. Expensive custom resolvers, such as those accessing external APIs, should receive an increased complexity value so the overall budget correctly models what that query actually costs. This requires a plugin on the complexity validation logic or direct configuration through the resolver mechanism.
8. Low versus high complexity compared
The following table illustrates why the same complexity limit is effective to different degrees depending on query structure, and why field-specific costs are decisive.
| Query Type | Fields | Depth | Estimated Complexity | Actual Load |
|---|---|---|---|---|
| Lean product list | sku, name, url_key (10 products) | 3 | ~40 | Low, 1 DB query |
| Product list with price | sku, name, price_range (20 products) | 5 | ~120 | Medium, 2 DB queries |
| Product with related_products | Product plus related (20 entries each) | 6 | ~800 | High, N+1 possible |
| Deeply nested query | 5 levels, 10 fields each | 10 | ~2000+ | Very high, multiple joins |
| Introspection query | __schema with all types | 4 | Very high (variable) | High, loads entire schema |
9. Summary
Query complexity is the most effective mechanism for protecting GraphQL APIs against overloading attacks while keeping load under control. Depth limits are simple to configure but only cover a subset of problematic query structures. Complexity limits with field-specific costs model actual server cost far more accurately and also protect against wide, flat queries. The combination of both, depth as a fast first barrier and complexity as a more substantive check, is the recommended approach for production APIs.
In Magento, complexity limits are configurable and active during the validation phase. For custom resolver extensions, the complexity configuration should be explicitly accounted for. Whoever calibrates the complexity budget based on real production queries (instead of guesswork) achieves the best balance between protection and flexibility. Monitoring complexity values per operation is the foundation for this calibration.
Measuring and Limiting Query Complexity: The Essentials at a Glance
Depth Limit
Simplest barrier, limits nesting depth. Prevents circular queries but does not detect wide queries. Always use together with a complexity limit.
Complexity Budget
Numeric total value per query. Calculated before execution, no resolver is called if it is exceeded. Calibrate based on production queries.
Field-Specific Costs
Weight expensive fields (joins, external APIs, list multipliers) higher. Uniform base costs for all fields produce an inaccurate model.
Magento Configuration
Configure graphql/validation/complexity_limit and depth_limit. Never carry developer mode into production, limits are often disabled there. Define custom resolver costs explicitly.