GraphQL demos beautifully. You define a schema, write a few resolvers, open the playground, and watch a single query pull back exactly the data the client asked for. It feels like the future.
Then it ships. Traffic arrives. The same query that returned in 40 milliseconds in development now takes four seconds, the database is pegged at 100% CPU, and nobody can explain why.
The gap between a GraphQL API that works in development and one that performs in production is almost never the framework. It is a small set of patterns that the demo never exercises. Here is how to close that gap.
Why GraphQL Punishes Naive Resolvers
REST gives you fixed endpoints. Each one runs a known set of database queries. You can read the code and predict the load.
GraphQL gives the client control over the shape of the response. That flexibility is the whole point, and it is also the trap. A single query can fan out into hundreds of database calls, and the resolver author often has no idea it is happening. The query looks small. The work behind it is not.
So the core discipline of a fast GraphQL API is this: the cost of resolving a query should be proportional to the data returned, not to the shape of the schema. Almost every performance problem below is a violation of that rule.
The N+1 Problem Is the Main Event
If you fix one thing, fix this. The N+1 query problem is responsible for the majority of slow GraphQL APIs we are asked to rescue.
Here is how it happens. A client asks for a list of 50 posts and the author of each one. Your posts resolver runs one query and returns 50 posts. Then, for each post, the author resolver runs its own query to fetch that author. That is 1 query for the posts plus 50 queries for the authors. Fifty-one round trips to the database for what should be two.
Now imagine the client also asks for each author's company, and each company's office. The fan-out multiplies. A query that looks innocent in the playground becomes thousands of database calls in production.
The fix is batching. Instead of resolving each author individually, you collect all the author IDs requested during a single GraphQL operation, then fetch them in one query. The standard tool for this in the Node ecosystem is DataLoader.
How DataLoader Actually Solves It
DataLoader does two things: batching and per-request caching.
Batching works by deferring. When a resolver asks DataLoader for author ID 7, DataLoader does not run a query immediately. It waits until the end of the current tick of the event loop, collects every author ID requested in that window, and runs one batched query for all of them. Fifty individual requests become one WHERE id IN (...) query.
Caching works within a single request. If two different parts of the query ask for the same author, DataLoader returns the cached result instead of fetching twice.
The critical rule that teams get wrong: create a fresh DataLoader for every request, not one shared instance for the whole server. A shared loader caches across users and leaks stale data between requests. Instantiate your loaders in the context function that runs per request, and they are scoped correctly.
DataLoader is not optional for a production GraphQL API. It is the baseline. Any resolver that fetches a related entity by ID should go through a loader.
Stop Unbounded Queries Before They Reach the Database
GraphQL clients can ask for anything the schema allows. Without limits, a client can request the first 100,000 records of a list, or nest relationships deep enough to force a combinatorial explosion of resolver calls. Some of this is malicious. Most of it is accidental. Both take your API down.
Three controls keep this in check.
Pagination should be mandatory on every list field. Do not expose a field that returns an unbounded array. Use cursor-based pagination with a maximum page size enforced on the server. If a client asks for 5,000 items, return an error or cap it at your limit. Never let the page size be whatever the client typed.
Query depth limiting rejects queries nested beyond a sensible threshold. A query that nests posts inside authors inside posts inside authors ten levels deep is almost always a mistake or an attack. Set a maximum depth and reject anything past it.
Query complexity analysis assigns a cost to each field and rejects queries whose total cost exceeds a budget. This is more sophisticated than depth limiting because it accounts for list fields that multiply work. A list field costs more than a scalar. A nested list costs more still. Libraries exist for every major GraphQL server to score and reject expensive queries before they execute.
Caching: The Layer Most Teams Skip
REST caches naturally. A GET request has a URL, and a URL is a cache key. CDNs and browsers cache it for free.
GraphQL breaks this because almost everything is a POST to a single endpoint, and the response shape varies per query. You have to build caching deliberately, at three levels.
Response caching stores the full result of a query for a short window. If the same query with the same variables comes in again, serve the cached response. This works well for public, read-heavy data that does not change every second. Modern GraphQL servers support response caching with per-type and per-field controls over what is cacheable and for how long.
Field-level caching, often backed by Redis, caches the result of expensive individual resolvers. If one field calls a slow third-party API or runs a heavy aggregation, cache that field's result independently of the rest of the query.
Persisted queries trade flexibility for speed and safety. Instead of sending the full query text on every request, the client sends a hash. The server looks up the pre-registered query for that hash. This shrinks request payloads, lets you cache at the CDN by hash, and lets you reject any query that is not on your approved list. For a product with a known set of client queries, this is one of the highest-leverage changes you can make.
Design the Schema for Performance, Not Just Correctness
A schema that models the domain perfectly can still be slow if it ignores how data is fetched.
Avoid fields that are expensive by default. If a User type has a totalLifetimeRevenue field that runs a heavy aggregation, every query that touches a user risks triggering it. Move expensive computed fields behind explicit, clearly named fields so clients opt in rather than paying for them by accident.
Be careful with deeply nested relationships. Every level of nesting is a potential fan-out. If your schema lets a client traverse from a company to all its users to all their posts to all the comments, you have handed them a query that can melt the database. Use pagination at every level and consider whether some traversals should simply not be exposed.
Think about where the data lives. If two fields on a type come from two different databases or services, resolving them together means two round trips. Sometimes the right answer is to denormalise so the common case is one fetch.
Measure What Is Actually Slow
Do not optimise by intuition. GraphQL gives you a precise unit to measure: the resolver.
Use tracing to record how long each resolver takes within a query. Apollo Server, GraphQL Yoga, and most production servers support per-resolver tracing through plugins. When a query is slow, the trace tells you exactly which resolver is the bottleneck. Usually it is one field doing an unbatched fetch or an uncached aggregation.
Log slow queries with their full text and variables so you can reproduce them. A query that is slow only for one customer is usually slow because of their data volume, and you cannot fix what you cannot reproduce.
Watch database query counts per request, not just response time. A request that returns in 200 milliseconds but runs 80 queries is a time bomb. It will fall over the moment the database is under load. Query count is the leading indicator. Response time is the lagging one.
Handle Errors Without Hiding Them
GraphQL returns a 200 status for most responses, with errors in a dedicated errors array. This trips up monitoring built for REST, where a 500 means something broke. With GraphQL, a query can partially succeed and partially fail, and your monitoring needs to read the errors array, not just the status code.
Make sure failed resolvers return clear, typed errors rather than crashing the whole query. A single failing field should not take down a response that is otherwise fine. And make sure your observability tooling counts GraphQL errors, or you will be blind to failures that never show up as HTTP errors.
A Sensible Production Checklist
When we sign off a GraphQL API as production-ready, it has all of the following. DataLoader on every related-entity fetch. Mandatory pagination with enforced maximum page sizes. Depth and complexity limits that reject abusive queries. Response and field-level caching for read-heavy data. Persisted queries if the client set is known. Per-resolver tracing in place. Database query counts monitored per request. Typed errors that fail gracefully.
None of this is exotic. It is the difference between a GraphQL API that demos well and one that survives contact with real traffic.
What to Do Next
If you already have a GraphQL API in production and it is slow, start by counting database queries per request on your three most common queries. If the count is far higher than the number of entities returned, you have an N+1 problem, and DataLoader is your first fix. If the count is reasonable but response times are high, look at caching and expensive computed fields. The trace will tell you where to look. Optimise the resolver the data points to, not the one you suspect.
Related Reading
- REST vs GraphQL: How to Choose for Your Next Web App - the decision framework before you commit to GraphQL at all
- What Made a REST API Buckle Under Load (and the Fix) - the same performance discipline applied to a Node REST API
- Backend Development - how we build production APIs that stay fast as traffic grows