Skip to content

Query limits

A GraphQL endpoint accepts whatever the client asks for. Without a ceiling, three shapes of query can take a production app down before auth even runs:

  • Recursive{ a { a { a { … 50 levels … } } } }. Tiny payload, massive resolve work.
  • Wide / flat — a single selection with hundreds of fields.
  • Volumetric fanout{ users { posts { comments { … } } } } where every step is has_many. Three levels of 10× fanout = 1000 rows resolved per parent.

nestrs wraps async-graphql’s two validation visitors that cover the three vectors. Both are on by default; tune the ceilings to your traffic.

LimitCatchesConfig fieldEnv var
DepthRecursivemax_depthNESTRS_GRAPHQL__MAX_DEPTH
ComplexityWide + fanout (with per-field annotations)max_complexityNESTRS_GRAPHQL__MAX_COMPLEXITY

Both are on by default (max_depth = 15, max_complexity = 2000). Keep both — depth alone misses wide queries, complexity alone misses queries that depth-bomb the parser before the visitor even runs.

src/module.rs — production pin
use nest_rs_graphql::{GraphqlConfig, GraphqlModule};
#[module(imports = [
GraphqlModule::for_root(GraphqlConfig {
max_depth: Some(15),
max_complexity: Some(1000),
..Default::default()
}),
])]
pub struct AppModule;
.env.production
NESTRS_GRAPHQL__MAX_DEPTH=15
NESTRS_GRAPHQL__MAX_COMPLEXITY=1000

The validation runs before any resolver, so a query that exceeds either limit costs the parse pass plus one visit — no DB round-trip, no dataloader batch, no Repo call.

The visitor walks the AST and tracks the deepest selection chain. A query nesting deeper than max_depth returns a validation error.

Typical production value: 10 to 20. Real queries rarely go past 6 even with multi-level relation traversal; 15 leaves headroom for admin tooling.

The visitor sums a score across every selected field. The default scoring is async-graphql’s: each field contributes 1 + child_complexity (one for the field itself, plus the sum of its selected sub-fields). A flat wide query of 500 fields scores 500; a 4-level relation chain scores roughly 4.

The per-field override is where complexity becomes a real anti-DoS mechanism. The #[expose] macro applies one automatically — see below.

Typical production value: 1000 to 5000, tuned from observed legitimate queries. The check is strict >max_complexity = 1000 admits a query scoring exactly 1000; pin 999 to reject it. Setting max_depth = Some(0) or max_complexity = Some(0) fails at boot with a config error: 0 would block every query, so None is the way to disable a check.

A HasMany relation resolves through an unbounded loader: the dataloader returns every child row of the parent. With async-graphql’s default scoring, that field costs 1 — no penalty for the fanout, even though 10 000 rows resolve.

#[expose] patches that automatically:

from the macro expansion
#[graphql(complexity = "10 * child_complexity")]
async fn users(&self, …) -> Vec<User> { … }

The 10× factor models a typical fan-out — three levels of HasMany (organizations → teams → members) score 10 × 10 × 10 = 1000. With the strict > check, max_complexity = 1000 lets that query through (exactly at the ceiling); pin 999 if you want to reject the canonical 3-deep chain. Tune the ceiling, not the factor — the factor is hard-wired across the framework so the calibration story stays the same app-wide.

BelongsTo resolvers keep async-graphql’s default 1 + child_complexity — they load at most one parent row, and an ability denial drops that to zero, so no fanout penalty is needed. That means HasMany and BelongsTo use asymmetric base scoring by default (multiplicative vs additive); the asymmetry is the point.

#[expose(complexity = …)] overrides the auto-emitted factor:

src/orgs/entity.rs
#[expose(name = "Org", service = …)]
#[sea_orm::model]
pub struct Model {
#[expose]
pub id: Uuid,
#[expose]
pub name: String,
#[sea_orm(has_many)]
#[expose(complexity = "20 * child_complexity")]
pub users: HasMany<crate::users::Entity>,
}

Two shapes:

  • Literal#[expose(complexity = 5)] pins a fixed score.
  • Expression#[expose(complexity = "10 * child_complexity")] uses async-graphql’s expression grammar with the reserved child_complexity variable. Pure arithmetic on child_complexity and literals only — the auto-emitted relation resolver takes no GraphQL arguments, so a name like first in the expression is treated as an unbound identifier and the macro expansion fails to compile.

The override applies to any exposed field, scalar or relation:

cost an expensive computed scalar
#[expose(name = "Report", service = …)]
pub struct Model {
#[expose]
pub id: Uuid,
/// Aggregates 24h of metric points server-side.
#[expose(complexity = 50)]
pub rolling_avg: f64,
}

Argument-aware expressions: the #[field_resolver] path

Section titled “Argument-aware expressions: the #[field_resolver] path”

first * child_complexity and similar expressions that read a GraphQL argument only resolve when the field resolver declares that argument. The auto-emitted relation does not. For a paginated relation taking first (or limit, or per_page), leave the relation unexposed (no #[expose]) and write the resolver by hand with a regular #[graphql(complexity = …)]:

// No #[expose] => no auto-emission; own it on the resolver.
#[sea_orm(has_many)]
pub posts: HasMany<crate::posts::Entity>,
// in the resolver file:
#[ComplexObject]
impl User {
#[graphql(complexity = "first * child_complexity")]
async fn posts(&self, first: i32) -> Vec<Post> { … }
}

Validation failures come back as a regular errors[] array with a 200 OK:

{
"errors": [{ "message": "Query is nested too deep." }],
"data": null
}

The message comes straight from async-graphql. Inspect it as you would any other validation error.

  1. Set max_depth = 15 from day one. Cheap, almost never a false positive, catches the recursive bomb.
  2. Set max_complexity = 5000 (generous) and log every denial at warn for a few weeks. Watch what hits the ceiling.
  3. Once denials cluster around abusive queries (deep relation chains on public endpoints, scrapers requesting hundreds of fields), tighten to the lowest value that doesn’t trip legitimate traffic.
  4. Override on individual fields only when a relation’s realistic fanout differs from the 10× default — a paginated cursor connection typically uses first * child_complexity; a known-bounded set can use a smaller literal.

The depth and complexity visitors live in async-graphql. The framework just exposes them through GraphqlConfig and threads the #[graphql(complexity = "…")] attribute through #[expose]. The expression grammar — child_complexity plus field arguments — is async-graphql’s; see its query complexity docs for the full reference.

  • crates/nest-rs-graphql/src/config.rs — the two Option<usize> config fields and their env parsing.
  • crates/nest-rs-graphql/src/resolver.rsbuild_schema applies the limits to the SchemaBuilder before .finish().
  • crates/nest-rs-resource-macros/src/relations.rs — the default_has_many_complexity() constant and the #[expose(complexity = …)] parsing.