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.
Quick Start
Section titled “Quick Start”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 })Complexity Metrics
Section titled “Complexity Metrics”Query Depth
Section titled “Query Depth”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 }}Complexity Score
Section titled “Complexity Score”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
Section titled “Field Count”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
Section titled “Alias Count”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 }}Field Complexity
Section titled “Field Complexity”Static Complexity
Section titled “Static Complexity”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()})Dynamic Complexity
Section titled “Dynamic Complexity”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 } }Object Field Complexity
Section titled “Object Field Complexity”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) }))Using Field Complexities
Section titled “Using Field Complexities”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 complexitiesconst router = toRouter(builder, Layer.empty, { complexity: { maxComplexity: 500 }})Error Responses
Section titled “Error Responses”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
Lifecycle Hooks
Section titled “Lifecycle Hooks”onExceeded Hook
Section titled “onExceeded Hook”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 }) }}Custom Calculators
Section titled “Custom Calculators”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 }})Built-in Calculators
Section titled “Built-in Calculators”import { defaultComplexityCalculator, depthOnlyCalculator, combineCalculators} from "@effect-gql/core"
// Default: calculates all metricsdefaultComplexityCalculator(defaultFieldCost)
// Depth only: faster, ignores complexity scoringdepthOnlyCalculator
// Combine multiple calculators (takes max of each metric)combineCalculators(calculator1, calculator2)WebSocket Subscriptions
Section titled “WebSocket Subscriptions”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 }) }})import { serve } from "@effect-gql/bun"
serve(router, Layer.empty, { port: 4000, subscriptions: { schema, complexity: { maxDepth: 5, maxComplexity: 500 } }})Environment Configuration
Section titled “Environment Configuration”Configure limits via environment variables:
# Complexity limitsGRAPHQL_MAX_DEPTH=10GRAPHQL_MAX_COMPLEXITY=1000GRAPHQL_MAX_ALIASES=10GRAPHQL_MAX_FIELDS=100GRAPHQL_DEFAULT_FIELD_COMPLEXITY=1Load 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)})Best Practices
Section titled “Best Practices”Start Conservative
Section titled “Start Conservative”Begin with strict limits and relax as needed based on real usage patterns:
{ complexity: { maxDepth: 5, maxComplexity: 100, maxFields: 50, maxAliases: 5 }}Use Dynamic Complexity for Pagination
Section titled “Use Dynamic Complexity for Pagination”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)})Combine with Rate Limiting
Section titled “Combine with Rate Limiting”Complexity limiting protects against expensive single queries. Combine with rate limiting to protect against many cheap queries:
// Example: 100 requests per minute per IPapp.use(rateLimiter({ max: 100, windowMs: 60000 }))
// Plus complexity limiting for expensive queriesconst router = toRouter(builder, layer, { complexity: { maxComplexity: 1000 }})Monitor Exceeded Limits
Section titled “Monitor Exceeded Limits”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) }) }}Analyzer Extension
Section titled “Analyzer Extension”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.
Basic Usage
Section titled “Basic Usage”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 } }}Configuration Options
Section titled “Configuration Options”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 }})All Metrics
Section titled “All Metrics”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
Section titled “Thresholds”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.
Combining with Complexity Limiting
Section titled “Combining with Complexity Limiting”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 addedNext Steps
Section titled “Next Steps”- Error Handling - Custom error responses
- Subscriptions - Real-time data with WebSockets
- Server Integration - Platform-specific setup