Performance & Security
GraphQL gives clients the power to ask for exactly the data they need. That same power lets clients ask for everything at once, nest queries twenty levels deep, and bring your servers to their knees. Performance and security in GraphQL are not afterthoughts — they are design constraints you must address before going to production.
The N+1 Problem
The N+1 problem is the most common performance issue in GraphQL. It happens when a resolver for a list field triggers one database query per item in the list.
Consider this query:
query {
orders(first: 50) {
edges {
node {
id
status
customer {
id
name
email
}
}
}
}
}
A naive implementation:
- One query to fetch 50 orders
- For each order, one query to fetch the customer
That is 1 + 50 = 51 database queries for a single GraphQL request. Scale this to nested fields (customer's address, each order's line items, each line item's product) and a single query can generate hundreds of database calls.
DataLoader
DataLoader (originally from Facebook) solves this by batching and caching database lookups within a single request.
Without DataLoader, the customer resolver fires 50 separate queries:
SELECT * FROM customers WHERE id = 'cus_1';
SELECT * FROM customers WHERE id = 'cus_2';
...
SELECT * FROM customers WHERE id = 'cus_50';
With DataLoader, all 50 customer IDs are collected and fetched in one batch:
SELECT * FROM customers WHERE id IN ('cus_1', 'cus_2', ..., 'cus_50');
DataLoader works by deferring resolver execution to the next tick of the event loop, collecting all requested keys, and making a single batched call. It also caches results within the request, so if the same customer appears in multiple orders, it is only fetched once.
The rule: every resolver that fetches data from a database or external service should use a DataLoader. No exceptions.
Eager Loading at the Query Level
An alternative to DataLoader is analyzing the query before execution and generating optimized database queries. If the query asks for orders { customer { ... } }, the resolver can issue a single JOIN query:
SELECT o.*, c.* FROM orders o JOIN customers c ON o.customer_id = c.id LIMIT 50;
This approach is more complex to implement but can be more efficient than batching for known query patterns.
Query Complexity Analysis
A GraphQL API without complexity limits is an open invitation for abuse. A single query can request every field on every type, nested to arbitrary depth:
query {
users(first: 100) {
edges {
node {
orders(first: 100) {
edges {
node {
items(first: 100) {
edges {
node {
product {
reviews(first: 100) {
edges {
node {
author {
orders(first: 100) {
# ...and so on
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
This query could touch millions of rows. You need to reject it before execution.
Depth Limiting
Set a maximum query depth. GitHub's GraphQL API limits query depth to prevent deeply nested queries:
Maximum depth: 10 levels
Any query nesting beyond the limit is rejected with an error before execution begins. This is the simplest defense and should be enabled on every GraphQL server.
Breadth Limiting
Limit the number of fields or items that can be requested at each level:
Maximum first/last argument: 100
Maximum fields per selection set: 200
GitHub caps first and last arguments at 100 for connection fields.
Cost Analysis
Assign a cost to each field and reject queries that exceed a cost budget. Simple fields (scalars) cost 1 point. List fields cost their limit multiplied by the cost of each item. Connections to external services cost more.
Field costs:
user.name = 1
user.email = 1
user.orders = 10 * (cost of Order)
order.items = 5 * (cost of LineItem)
Query budget: 10,000 points per request
GitHub's GraphQL API uses a node-based cost model. Each query is limited to requesting at most 500,000 nodes, calculated before execution:
Cost calculation:
users(first: 50) = 50 nodes
users.orders(first: 10) per user = 50 * 10 = 500 nodes
orders.items(first: 5) per order = 500 * 5 = 2,500 nodes
Total: 3,050 nodes (within budget)
Persisted Queries
In production, letting clients send arbitrary query strings is a security risk and a performance cost. Persisted queries replace the query string with a hash.
How It Works
- During development or build time, the client registers its queries with the server
- Each query gets a unique hash (SHA-256 of the query string)
- In production, the client sends only the hash and variables
{
"id": "abc123def456",
"variables": {
"userId": "usr_789"
}
}
Instead of:
{
"query": "query GetUser($userId: ID!) { user(id: $userId) { id name email orders(first: 10) { edges { node { id status } } } } }",
"variables": {
"userId": "usr_789"
}
}
Benefits
- Security — clients cannot send arbitrary queries. Only pre-approved queries are accepted. This eliminates most query-based attacks.
- Performance — smaller request payloads (hash vs full query string). The server can pre-plan execution for known queries.
- Caching — persisted queries can be cached at the CDN level using the hash as a cache key, similar to REST endpoint caching.
Automatic Persisted Queries (APQ)
Apollo's APQ protocol is a middle ground. The client sends the query hash first. If the server recognizes it, it executes immediately. If not, the client resends with the full query string, and the server caches it for future requests.
This requires no build-time registration step but provides the same performance benefits for repeated queries.
Rate Limiting by Query Cost
Traditional rate limiting counts requests: 1,000 requests per minute. For GraphQL, this is insufficient because one request can do the work of a hundred REST calls.
Rate limit by cost instead:
Budget: 10,000 points per minute
Simple query (fetch one user): 5 points
Medium query (user + orders): 50 points
Heavy query (user + orders + items + products): 500 points
GitHub's GraphQL API returns rate limit information in every response:
{
"data": { "..." : "..." },
"extensions": {
"rateLimit": {
"limit": 5000,
"cost": 12,
"remaining": 4988,
"resetAt": "2024-01-15T13:00:00Z"
}
}
}
The cost field tells the client how much this query cost. The remaining field tells them how much budget is left. Clients can proactively avoid hitting limits.
Introspection in Production
GraphQL introspection lets clients query the schema itself:
query {
__schema {
types {
name
fields {
name
type { name }
}
}
}
}
This is invaluable during development (it powers GraphiQL and IDE autocompletion) but risky in production. Introspection exposes your entire schema, including internal types, deprecated fields, and field descriptions that may contain sensitive information.
Options
Disable introspection entirely — the safest option for APIs with persisted queries. If clients only send pre-registered queries, they do not need introspection.
Restrict introspection to authenticated users — allow introspection for developers with valid API keys but block it for anonymous requests.
Allow introspection, accept the risk — if your schema is public (like GitHub's), introspection is part of the developer experience. GitHub publishes their schema and allows introspection because their API is a public product.
The decision depends on your API's audience. Internal APIs serving known clients should disable introspection. Public APIs for third-party developers may benefit from keeping it enabled.
Query Allowlisting
The strictest security model: only allow specific, pre-approved queries. Every query the client sends must match an entry in the allowlist. Unknown queries are rejected.
Allowlist:
"abc123" -> query GetUser($id: ID!) { user(id: $id) { id name email } }
"def456" -> query ListOrders($first: Int) { orders(first: $first) { ... } }
"ghi789" -> mutation CreateOrder($input: CreateOrderInput!) { ... }
Request with unknown query -> 403 Forbidden
This is the most secure option but also the most restrictive. It works well for APIs where you control all clients (mobile apps, single-page apps). It does not work for APIs where third-party developers write their own queries.
Common Pitfalls
- No DataLoader — writing naive resolvers that query the database per item in a list. The N+1 problem will surface immediately under real traffic.
- No depth or cost limits — allowing arbitrary query complexity. A single malicious or accidentally complex query can overwhelm the server.
- Rate limiting by request count — treating all GraphQL requests as equal. A simple field lookup and a 500-node nested query have vastly different server costs.
- Introspection in production without consideration — leaving introspection enabled by default. Make a deliberate decision based on your API's audience.
- No query logging — not logging the queries clients send. Without query logs, you cannot identify expensive queries, debug performance issues, or understand usage patterns.
- Ignoring resolver-level caching — DataLoader caches within a request, but frequently accessed data (product catalog, configuration) benefits from cross-request caching with TTLs.
Key Takeaways
- Use DataLoader for every resolver that fetches external data. Batching and per-request caching eliminate the N+1 problem.
- Implement depth limiting, breadth limiting, and cost analysis to prevent expensive queries from reaching your resolvers.
- Use persisted queries in production to reduce payload size, improve security, and enable CDN caching.
- Rate limit by query cost, not request count. A query that fetches 5,000 nodes should cost more than a query that fetches 5.
- Make a deliberate decision about introspection: disable it for internal APIs, consider keeping it for public APIs, and always restrict it for unauthenticated requests.
- Log every query. You cannot optimize or secure what you cannot measure.