GraphQL Production Practices and Pitfalls
GraphQL is flexible and powerful, but production brings pitfalls: N+1 queries, complex-query attacks, difficult caching, and overly coarse authorization. This article covers DataLoader, depth and complexity limits, persisted queries, caching strategies, and field-level authorization.
Key Points
- Topic Focus: Next.js、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
GraphQL Production Practices and Pitfalls
Over the past few years, GraphQL has gone from a "cutting-edge toy" to a serious production option. It genuinely solves several problems that REST handles poorly: fetching data on demand, strongly typed contracts, aggregation across multiple sources, and frontend-driven development. But once you put it under real production traffic, you encounter a familiar set of pitfalls: N+1 queries, maliciously complex queries, nearly impossible caching, authorization granularity spiraling out of control, and painful Schema evolution.
This article distills the pitfalls I have encountered and the solutions I have used in production into a practical checklist.
1. The Real Value of GraphQL
First, be clear about why you are using GraphQL so that you do not adopt it for its own sake.
query {
post(id: "1") {
title
author { name avatar }
comments(first: 3) { text createdAt }
}
}
In one request, the frontend declares which fields it needs, and the backend returns them all at once. Its value comes down to three things:
- Fetch data on demand: Avoid underfetching, which requires another request for missing fields, and overfetching, which fills lists with unnecessary fields. The benefits are substantial on mobile devices and poor networks.
- Strongly typed contracts: The Schema is the single source of truth. Frontend and backend teams can work in parallel, TypeScript types can be generated directly from the Schema, and both sides stay aligned at virtually no cost.
- Aggregation: Data from multiple microservices can be combined through a GraphQL gateway, giving the frontend a single graph.
The tradeoff is substantially greater backend complexity. The following problems either do not exist in REST or are much less severe there.
2. The N+1 Problem: GraphQL's "Original Sin"
This is the first problem you will encounter when taking GraphQL to production.
Consider this query:
query {
posts {
title
author { name }
}
}
In a naive resolver implementation, posts returns 10 posts, and the author field of each post triggers a separate SQL query for its author. Ten posts produce 1 list query + 10 author queries = 11 queries. For N posts, that becomes N+1.
This mistake is less common with REST because REST endpoints usually have a fixed granularity, making a missing join obvious to the backend developer. GraphQL resolvers naturally resolve data field by field, so N+1 queries are easy to introduce.
The solution: DataLoader.
DataLoader has two core capabilities: batching and caching.
import DataLoader from 'dataloader'
const userLoader = new DataLoader<string, User>(async (ids) => {
// ids is the deduplicated array of all user IDs requested in this batch
const users = await db.user.findMany({ where: { id: { in: ids } } })
// The return order must match ids exactly
return ids.map(id => users.find(u => u.id === id))
})
const resolvers = {
Post: {
author: (post, _, ctx) => ctx.userLoader.load(post.authorId),
},
}
load(1), load(2), load(3)... called within the same event-loop tick are combined into one findMany({ where: { id: { in: [1,2,3] } } }) call. A second load of the same ID is served directly from the cache. N+1 becomes two queries.
The critical point: DataLoader must be attached to the request context as a per-request instance, not created as a global singleton. Otherwise, user A may receive data cached for user B, causing an authorization incident that is extremely difficult to diagnose.
3. Complex Query Attacks: The Most Easily Overlooked DoS Vector
GraphQL gives clients the ability to construct arbitrary queries, which also gives attackers the ability to construct arbitrary disasters.
A classic attack looks like this:
query {
user(id: "1") {
friends {
friends {
friends {
friends { name }
}
}
}
}
}
Nest a cycle a dozen levels deep and the database can be overwhelmed immediately. Alternatively, an attacker can put 1,000 fields or pagination such as first: 100000 into one query and exhaust backend CPU and memory.
Use both lines of defense together.
1. Depth limiting.
import depthLimit from 'graphql-depth-limit'
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)],
})
Reject anything nested more than five levels deep. It is simple and blunt, but blocks 90% of recursive attacks.
2. Complexity analysis, or cost analysis.
Assign each field a "cost," calculate the total cost of the query, and reject it when it exceeds a threshold.
import costAnalysis from 'graphql-cost-analysis'
const server = new ApolloServer({
validationRules: [
costAnalysis({
maximumCost: 1000,
variables: {}, // Read from the request
costMap: {
user: { multipliers: ['first'], useMultipliers: true, complexity: 4 },
},
}),
],
})
For a field with a multiplier such as user(first: 100), the cost is 4 * 100 = 400. A value such as first: 100000 immediately exceeds the limit and is rejected.
Depth limiting alone is not enough because an attacker can spread fields horizontally. Complexity analysis alone is not enough because recursive nesting still needs depth as a backstop. Use both together.
4. Persisted Queries: Required Knowledge for Production
Production queries should be enumerable, auditable, and enforceable. Allowing clients to submit arbitrary ad hoc queries is equivalent to exposing a database query interface to the entire world.
How persisted queries work:
At build time, the client hashes every query and stores the query -> hash mapping. At runtime, the client sends only the hash:
POST /graphql
{ "id": "abc123hash", "variables": { "id": "1" } }
The server can operate in two modes:
- APQ (Automatic Persisted Queries): When the server does not recognize a hash, it asks the client to send the complete query and stores it in a cache such as Redis. Future requests use the hash directly. This reduces client payload size while allowing the server to learn queries incrementally.
- Strict mode (recommended for production): The server only accepts a pre-registered list of queries, or allowlist, and rejects everything else. CI pushes the query hashes from the build artifacts to the backend.
There are three benefits:
- Security: Clients cannot construct arbitrary queries, eliminating the complexity attacks described above at the source.
- Lower bandwidth usage: A production request body contains only a hash and variables, totaling just a few dozen bytes.
- Observability: Each hash corresponds to a known query, so logs and monitoring can identify exactly which query is slow.
5. Caching: GraphQL's Most Painful Problem
REST uses GET and naturally benefits from HTTP caching and CDNs. GraphQL uses POST by default, and intermediaries do not cache POST requests, so "adopting GraphQL makes the CDN useless."
The solutions operate at several layers.
1. Use GET with persisted queries.
With a persisted query, the request becomes GET /graphql?id=abc123hash&variables=.... The URL is cacheable and can use the CDN directly. Apollo Server and Yoga both support this.
2. Use the @cacheControl directive for server-side caching.
type Post @cacheControl(maxAge: 60) {
id: ID!
title: String
}
This tells the server how long the type may be cached. Apollo can cache complete responses or individual fields at a finer granularity.
3. Use DataLoader's per-request cache.
Repeatedly loading the same ID within one request no longer hits the database. This is built into DataLoader at no additional cost.
4. Use a client-side cache, such as an In-Memory Cache or Relay Store.
GraphQL's normalized cache is more powerful than REST caching. It stores entities by id, and field updates propagate automatically. If a mutation changes a username, every list that references that user updates automatically, which REST cannot do by itself.
Remember this principle: GraphQL caching primarily happens on the client and server, not at the CDN, unless persisted queries are sent over GET.
6. Authorization: Do Not Confuse Authentication with Authorization
The most common authorization mistake in GraphQL is to check only once at the resolver entry point and assume that is enough.
Authentication and authorization are two different things:
- Authentication: Who are you? Handle this at the HTTP layer by parsing the token and attaching
userto the context. - Authorization: What are you allowed to do? Every field must be checked.
Authorization has two levels of granularity:
1. Operation-level authorization, which is coarse-grained. Check it through the context:
const resolvers = {
Mutation: {
deletePost: (_, { id }, ctx) => {
if (!ctx.user) throw new Error('Not authenticated')
// ...
},
},
}
This is enough for simple cases. But GraphQL is a graph. You may protect the entry point, but can you protect every field?
query {
user(id: "someone-else-id") {
email # Sensitive field
paymentInfo # Sensitive field
}
}
The user field has resolved, but who is allowed to see email and paymentInfo? Field-level authorization is mandatory.
2. Field-level authorization, which is fine-grained. Use schema directives or middleware:
type User {
id: ID!
name: String!
email: String @auth(requires: ADMIN)
paymentInfo: PaymentInfo @auth(requires: ADMIN)
}
function auth(directive) {
return (next) => (root, args, ctx, info) => {
if (!ctx.user?.roles?.includes(directive.requires)) {
throw new ForbiddenError('Not authorized to access this field')
}
return next()
}
}
Production rule: Assume every field can be requested by any client. Deny by default and allow explicitly. Never rely on the claim that "the frontend will not request this field." GraphQL queries are declared by the client, so that assumption does not hold.
7. Schema Design: A Decision That Will Affect the Next Three Years
Schema design debt is especially painful to repay. Follow these principles:
1. Naming. Use business terminology rather than database column names. Something like user.is_del should become user.isDeleted, or simply user.status. The Schema is for the frontend, not an ORM mapping.
2. Use the Connection or Relay specification for pagination instead of returning bare arrays.
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
Cursor pagination is more stable than offset pagination because changing data does not shift or duplicate results. The Connection specification gives you a standard structure for pagination plus metadata, allowing frontend components to be reused.
3. Error handling. The GraphQL convention is HTTP 200 with errors in the response body. This is the exact opposite of REST and often confuses newcomers.
{
"data": { "post": null },
"errors": [
{ "message": "Access denied", "path": ["post"], "extensions": { "code": "FORBIDDEN" } }
]
}
errors is an array because multiple fields in one query may fail independently. Partial success plus partial failure is normal. Do not immediately wrap the entire resolver in try/catch and return 500.
Add extensions.code to errors so the frontend can branch on the code instead of parsing the message string. The message is for people; the code is for programs.
4. Schema evolution. Add fields, never remove them. If a field must be removed, mark it with @deprecated first:
type User {
name: String! @deprecated(reason: "Use displayName instead")
displayName: String!
}
Give clients a migration window. Removing a field is a breaking change and can immediately break someone else's production client.
8. When to Use GraphQL and When Not to Use It
Finally, consider technology selection. GraphQL is not a silver bullet, and it creates more work in many situations.
Use GraphQL when:
- The frontend changes frequently and field requirements evolve often, as with operations dashboards, content sites, and social apps
- You need to aggregate multiple microservices or data sources
- You are building for mobile, where bandwidth and request counts matter
- Frontend and backend teams are separate and need a clear contract
Do not use GraphQL when:
- You have simple CRUD over one or two entities and a few tables. REST handles it in a few lines, while GraphQL would be overengineering
- You are exposing a public API. REST's discoverability, caching, and documentation ecosystem are friendlier to third parties
- You need high-throughput internal RPC. gRPC or tRPC is much lighter than GraphQL
- The team lacks schema-governance discipline. A disorderly Schema becomes even harder to maintain than REST
My observation is that GraphQL delivers enormous value for medium-to-large applications with complex frontends and multiple aggregated data sources. For small projects and pure CRUD, REST plus a little OpenAPI is the more economical choice.
Summary
When taking GraphQL to production, remember these points:
- N+1 is the original sin, DataLoader is the remedy, and it must be instantiated per request.
- Complex-query attacks are a real threat. Use depth limiting and complexity analysis together.
- Persisted queries are required knowledge for production. They improve security, save bandwidth, and enable CDN caching.
- Authorization must operate at the field level. Deny by default, allow explicitly, and never trust that "the frontend will not request it."
- The Schema is a long-term asset: use business semantics for names, use Connections for pagination, and only add fields rather than removing them.
In one sentence: GraphQL's flexibility should empower the frontend, not burden the backend. The backend must constrain everything that needs constraining, including query allowlists, field authorization, and complexity limits. Giving clients unrestricted freedom may sound attractive, but you will pay ten times the cost in production.
GraphQL is not meant to "replace REST." It is a different contract model. Choose the right use case and it can double frontend-backend collaboration efficiency. Choose the wrong one and it becomes a framework with ten times the complexity of REST.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.