Skip to content

Query Complexity Limiting

Query complexity limiting protects your GraphQL API from expensive or malicious queries. Effect GraphQL provides a comprehensive system for analyzing and limiting query complexity before execution.

Enable basic complexity limiting by adding the complexity option to your router configuration:

import { toRouter, GraphQLSchemaBuilder } from "@effect-gql/core"
import { serve } from "@effect-gql/node"
import { Effect, Layer } from "effect"
import * as S from "effect/Schema"
const builder = GraphQLSchemaBuilder.empty.pipe(
query("users", {
type: S.Array(UserSchema),
resolve: () => Effect.succeed([/* ... */])
})
)
const router = toRouter(builder, Layer.empty, {
graphiql: true,
complexity: {
maxDepth: 10, // Maximum query nesting depth
maxComplexity: 1000, // Maximum total complexity score
maxFields: 100, // Maximum number of fields
maxAliases: 10 // Maximum number of aliases
}
})
serve(router, Layer.empty, { port: 4000 })

Query depth measures how deeply nested a query is. Each level of nesting adds to the depth:

# Depth: 1
{ users { id } }
# Depth: 2
{ users { posts { id } } }
# Depth: 3
{ users { posts { comments { id } } } }
# Depth: 4 - might exceed limit!
{ users { posts { comments { author { name } } } } }

Use maxDepth to prevent deeply nested queries that could be expensive:

{
complexity: {
maxDepth: 5 // Reject queries nested more than 5 levels
}
}

The complexity score represents the total cost of executing a query. By default, each field costs 1 point:

# Complexity: 4 (user + id + name + email)
{ user(id: "1") { id name email } }

You can assign custom costs to expensive fields:

const builder = GraphQLSchemaBuilder.empty.pipe(
query("users", {
type: S.Array(UserSchema),
complexity: 10, // This field costs 10 points
resolve: () => fetchAllUsers()
}),
query("user", {
type: UserSchema,
complexity: 1, // This field costs 1 point
resolve: (args) => fetchUser(args.id)
})
)

Field count limits the total number of fields in a query:

# Field count: 5 (user + id + name + email + posts)
{ user(id: "1") { id name email posts { id } } }
{
complexity: {
maxFields: 50 // Reject queries with more than 50 fields
}
}

Alias count limits how many times fields can be aliased. This prevents batch attacks:

# Alias count: 4
{
a1: expensiveQuery { id }
a2: expensiveQuery { id }
a3: expensiveQuery { id }
a4: expensiveQuery { id }
}
{
complexity: {
maxAliases: 10 // Reject queries with more than 10 aliases
}
}

Assign a fixed cost to fields that are consistently expensive:

query("allUsers", {
type: S.Array(UserSchema),
complexity: 50, // Fetching all users is expensive
resolve: () => db.users.findAll()
})

Calculate complexity based on query arguments for paginated or filtered fields:

query("users", {
type: S.Array(UserSchema),
args: S.Struct({ limit: S.optional(S.Number) }),
// Complexity scales with limit argument
complexity: (args) => (args.limit ?? 10) * 2,
resolve: (args) => db.users.findMany({ take: args.limit })
})

This query has different costs depending on the limit:

# Complexity: 20 (limit 10 * 2)
{ users { id } }
# Complexity: 200 (limit 100 * 2)
{ users(limit: 100) { id } }
# Complexity: 2000 (limit 1000 * 2) - likely rejected!
{ users(limit: 1000) { id } }

Add complexity to computed fields on types:

GraphQLSchemaBuilder.empty.pipe(
objectType({ name: "User", schema: UserSchema }),
field("User", "recommendations", {
type: S.Array(ProductSchema),
complexity: 25, // ML-based recommendations are expensive
resolve: (user) => recommendationEngine.forUser(user.id)
})
)

To use field complexities defined on your builder, extract them and pass to the router:

const builder = GraphQLSchemaBuilder.empty.pipe(
query("expensiveQuery", {
type: S.String,
complexity: 100,
resolve: () => Effect.succeed("result")
})
)
const schema = builder.buildSchema()
const fieldComplexities = builder.getFieldComplexities()
const router = makeGraphQLRouter(schema, Layer.empty, {
complexity: {
maxComplexity: 500
},
fieldComplexities // Pass the extracted complexities
})

Or use toRouter which handles this automatically:

// toRouter automatically extracts field complexities
const router = toRouter(builder, Layer.empty, {
complexity: { maxComplexity: 500 }
})

When a query exceeds limits, clients receive a detailed error response:

{
"errors": [{
"message": "Query complexity of 150 exceeds the maximum allowed complexity of 100",
"extensions": {
"code": "COMPLEXITY_LIMIT_EXCEEDED",
"limitType": "complexity",
"limit": 100,
"actual": 150
}
}]
}

The extensions.limitType indicates which limit was exceeded:

  • "depth" - Query nesting depth
  • "complexity" - Total complexity score
  • "fields" - Field count
  • "aliases" - Alias count

Execute custom logic when limits are exceeded (e.g., logging, metrics):

{
complexity: {
maxComplexity: 1000,
onExceeded: (info) => Effect.gen(function* () {
yield* Effect.log("Complexity limit exceeded", {
limitType: info.exceededLimit,
limit: info.limit,
actual: info.actual,
query: info.query
})
// The query is still rejected after this runs
})
}
}

For advanced use cases, implement custom complexity calculators:

import { ComplexityCalculator, ComplexityResult } from "@effect-gql/core"
const myCalculator: ComplexityCalculator = (
document, // Parsed GraphQL document
schema, // GraphQL schema
variables, // Query variables
operationName, // Operation name
fieldComplexities // Field complexity map
) => Effect.succeed<ComplexityResult>({
depth: 5,
complexity: 42,
fieldCount: 10,
aliasCount: 2
})
const router = toRouter(builder, Layer.empty, {
complexity: {
maxComplexity: 100,
calculator: myCalculator
}
})
import {
defaultComplexityCalculator,
depthOnlyCalculator,
combineCalculators
} from "@effect-gql/core"
// Default: calculates all metrics
defaultComplexityCalculator(defaultFieldCost)
// Depth only: faster, ignores complexity scoring
depthOnlyCalculator
// Combine multiple calculators (takes max of each metric)
combineCalculators(calculator1, calculator2)

Complexity limits also apply to WebSocket subscriptions:

import { serve } from "@effect-gql/node"
serve(router, Layer.empty, {
port: 4000,
subscriptions: {
schema,
complexity: {
maxDepth: 5,
maxComplexity: 500
},
onConnect: (params) => Effect.succeed({ user: params.user })
}
})

Configure limits via environment variables:

Terminal window
# Complexity limits
GRAPHQL_MAX_DEPTH=10
GRAPHQL_MAX_COMPLEXITY=1000
GRAPHQL_MAX_ALIASES=10
GRAPHQL_MAX_FIELDS=100
GRAPHQL_DEFAULT_FIELD_COMPLEXITY=1

Load with GraphQLRouterConfigFromEnv:

import { Effect } from "effect"
import { GraphQLRouterConfigFromEnv, makeGraphQLRouter } from "@effect-gql/core"
const program = Effect.gen(function* () {
const config = yield* GraphQLRouterConfigFromEnv
// config.complexity contains the loaded limits
const router = makeGraphQLRouter(schema, layer, config)
})

Begin with strict limits and relax as needed based on real usage patterns:

{
complexity: {
maxDepth: 5,
maxComplexity: 100,
maxFields: 50,
maxAliases: 5
}
}

Always scale complexity with pagination arguments:

query("posts", {
args: S.Struct({
first: S.optional(S.Number),
last: S.optional(S.Number)
}),
complexity: (args) => {
const count = args.first ?? args.last ?? 10
return count * 2 // Each post costs 2 points
},
resolve: (args) => fetchPosts(args)
})

Complexity limiting protects against expensive single queries. Combine with rate limiting to protect against many cheap queries:

// Example: 100 requests per minute per IP
app.use(rateLimiter({ max: 100, windowMs: 60000 }))
// Plus complexity limiting for expensive queries
const router = toRouter(builder, layer, {
complexity: { maxComplexity: 1000 }
})

Use the onExceeded hook to track blocked queries:

{
complexity: {
maxComplexity: 1000,
onExceeded: (info) =>
MetricsService.increment("graphql.complexity.exceeded", {
limitType: info.exceededLimit,
query: info.query.substring(0, 100)
})
}
}

Sometimes you want to report complexity metrics to clients without blocking requests. The Analyzer extension adds complexity information to the response’s extensions field, similar to async-graphql’s Analyzer.

import { createAnalyzerExtension, extension, GraphQLSchemaBuilder } from "@effect-gql/core"
const analyzer = createAnalyzerExtension()
const builder = GraphQLSchemaBuilder.empty.pipe(
extension(analyzer),
query("users", {
type: S.Array(UserSchema),
resolve: () => fetchUsers()
})
)

Responses will include complexity metrics:

{
"data": { "users": [...] },
"extensions": {
"analyzer": {
"complexity": 42,
"depth": 3
}
}
}
const analyzer = createAnalyzerExtension({
// Which metrics to include (defaults shown)
includeComplexity: true,
includeDepth: true,
includeFieldCount: false,
includeAliasCount: false,
// Custom key in extensions (default: "analyzer")
key: "queryStats",
// Default cost per field (default: 1)
defaultFieldComplexity: 1,
// Log warnings when thresholds are exceeded
thresholds: {
depth: 10,
complexity: 500,
fieldCount: 100,
aliasCount: 20
}
})

Enable all metrics for comprehensive analysis:

const analyzer = createAnalyzerExtension({
includeComplexity: true,
includeDepth: true,
includeFieldCount: true,
includeAliasCount: true,
key: "queryAnalysis"
})

Response:

{
"data": { ... },
"extensions": {
"queryAnalysis": {
"complexity": 42,
"depth": 3,
"fieldCount": 15,
"aliasCount": 2
}
}
}

Thresholds log warnings when exceeded but do not block the request:

const analyzer = createAnalyzerExtension({
thresholds: {
depth: 5, // Warn if depth > 5
complexity: 100 // Warn if complexity > 100
}
})

This is useful for monitoring expensive queries in development or staging before enforcing limits in production.

Use both the analyzer (for reporting) and complexity limiting (for blocking):

const analyzer = createAnalyzerExtension()
const builder = GraphQLSchemaBuilder.empty.pipe(
extension(analyzer),
query("users", { type: S.Array(UserSchema), resolve: ... })
)
const router = toRouter(builder, Layer.empty, {
// Block queries that exceed limits
complexity: {
maxDepth: 10,
maxComplexity: 1000
}
})
// Responses will include analyzer data for allowed queries
// Blocked queries return errors before extensions are added