What Is a Downside of GraphQL?
GraphQL fixed a lot of REST’s pain points — but it did not fix everything for free. This guide walks through exactly what GraphQL costs you, why those costs exist, and how real companies like Facebook, GitHub, Shopify, and Netflix engineer around them, from first principles all the way to production.
Introduction & History
Imagine you walk into a restaurant. In one kind of restaurant, the waiter brings you a giant tray with every dish the kitchen has ever made — a full turkey, ten side dishes, three desserts — even though you only wanted a bowl of soup. You have to eat around all the extra food to find what you asked for. In another kind of restaurant, the waiter takes your order one item at a time: “I’ll get your soup. Now let me go back for your bread. Now let me go get your drink.” You get exactly what you want, but you have to keep sending the waiter back and forth.
These two restaurants are a simple picture of the two big ways of building web APIs: REST (the first restaurant, where an endpoint returns a fixed, pre-decided tray of data) and GraphQL (a query language that lets the customer, meaning the app, describe exactly what it wants in one single order).
GraphQL is a query language for APIs, and also a server-side engine for running those queries against your data. It was created inside Facebook (now Meta) in 2012 to solve real problems with their mobile News Feed app, and it was released to the public as an open-source project in 2015. Today it is maintained by the GraphQL Foundation, part of the Linux Foundation, and it is used by companies like Facebook, GitHub, Shopify, Twitter/X, Airbnb, PayPal, and Netflix.
| Year | Milestone |
|---|---|
| 2012 | Facebook builds GraphQL internally to fix mobile News Feed performance problems. |
| 2015 | GraphQL is open-sourced to the public. |
| 2016 | GraphQL reaches version 1.0 specification stability. |
| 2018 | The GraphQL Foundation is formed under the Linux Foundation for neutral governance. |
| 2019–present | Apollo Federation, Relay, and other tools mature GraphQL for large-scale, multi-team use. |
This guide specifically answers “what is a downside of GraphQL?” — so while we will explain the basics so nobody gets lost, the centre of gravity is the honest, technical trade-offs: what GraphQL costs you in performance, caching, security, complexity, and operations, and how experienced teams manage those costs in production.
1.1 Why “downsides” deserve their own deep dive
A lot of tutorials online only celebrate GraphQL’s strengths, because those strengths are easy to demonstrate in a small example: one query, one response, no over-fetching, looks great on a slide. But engineering decisions are judged properly only once you look at what happens at real scale, with real traffic, real attackers, and real teams of people who did not all agree on every naming convention. This guide takes that harder, more honest path on purpose, because understanding a technology’s weaknesses is what actually prepares you to use it responsibly — and it’s also exactly what interviewers are testing when they ask “what is a downside of GraphQL?”
1.2 REST vs GraphQL at a glance
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Many, one per resource/action | Usually one endpoint (/graphql) |
| Response shape | Fixed by the server per endpoint | Chosen by the client per request |
| Over/under-fetching | Common problem | Solved by design |
| HTTP caching (CDN, browser) | Native and simple | Requires custom strategy |
| HTTP status codes | Meaningful (404, 500, etc.) | Mostly 200, errors in body |
| N+1 query risk | Low (hand-written joined queries) | High unless actively batched |
| File uploads | Native support | Not part of the core spec |
| Rate limiting | Simple, request-count based | Needs query-cost-based limiting |
| Learning curve | Lower for most teams | Higher (schema, resolvers, batching) |
This table is the map for the rest of the guide: nearly every row on the right-hand “GraphQL” side that looks different from REST is either an advantage GraphQL is famous for, or a downside this guide is about to explain in depth — often, as you’ll see, the very same design choice is the source of both.
The Problem GraphQL Solves (So We Understand the Trade-off)
To understand a downside, you first have to understand the upside it is attached to. Every design decision in engineering is a trade-off — you rarely get a benefit for free.
2.1 Over-fetching
Over-fetching means the server sends you more data than you actually need. This is like asking someone “what’s your name?” and getting back their name, address, phone number, blood type, and shoe size. You only wanted the name, but you’re stuck carrying (downloading, parsing, and storing) all that extra weight.
Example: A REST endpoint GET /users/42 might always return the user’s id, name, email, address, date of birth, and account settings — even if your mobile screen only needs to show the user’s name.
2.2 Under-fetching
Under-fetching is the opposite problem: one REST endpoint doesn’t give you enough, so you have to call several endpoints in a row to build one screen. If you want a user’s name AND their last five orders AND their shipping address, in REST you might need three separate round trips: GET /users/42, then GET /users/42/orders, then GET /orders/.../shipping.
GraphQL was built to solve exactly these two problems using a single idea: let the client describe the exact shape of data it wants, in one request, and the server returns exactly that shape — no more, no less.
Core Concepts
Before we dig into the downsides, let’s define the vocabulary in plain English. Every term below gets a simple analogy and a real example.
3.1 Schema
The contract every client and server must share
What it is: A schema is a contract, written in a special language called SDL (Schema Definition Language), that describes every type of data your API can return and every operation a client can perform.
Why it exists: Without a shared contract, the client and server would constantly disagree about what data looks like, causing bugs.
Analogy: A schema is like a restaurant’s printed menu. The menu tells you exactly what dishes exist and what ingredients are in each one — you can’t order something that isn’t on the menu.
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [String!]!
}
type Query {
user(id: ID!): User
}
3.2 Query, Mutation, and Subscription
Query reads data (like asking a question). Mutation changes data — creates, updates, or deletes (like giving an instruction). Subscription keeps a connection open so the server can push live updates (like subscribing to a YouTube channel and getting notified of new videos).
query {
user(id: "42") {
name
orders {
total
}
}
}
This single query returns exactly the user’s name and their orders’ totals — nothing more.
3.3 Resolver
The tiny functions behind every field
What it is: A resolver is a small function on the server that knows how to fetch the actual data for one field in the schema.
Analogy: If the schema is the menu, resolvers are the individual kitchen stations — the grill station “resolves” the burger, the salad station “resolves” the salad. Each field on the menu has one station responsible for producing it.
Why this matters for downsides: Because every single field can have its own resolver, and resolvers can call resolvers, a seemingly simple query can quietly trigger dozens or hundreds of small database calls. This is the root cause of the most famous GraphQL downside, which we cover in Chapter 06.
3.4 Type System
GraphQL is strongly typed: every field has a declared type (String, Int, Float, Boolean, ID, or a custom object type), and the server validates every query against the schema before running it. This catches many bugs early, which is a genuine advantage — but it also means the schema becomes a large, evolving piece of shared infrastructure that an entire company must agree on and maintain, which is itself a downside in team coordination (more in Chapter 07).
Architecture & Components
A production GraphQL system usually has these pieces working together:
Client
A web or mobile app that sends a GraphQL query/mutation as a string, usually over HTTP POST.
GraphQL Server
Parses the query, validates it against the schema, and executes resolvers (e.g., Apollo Server, GraphQL Java, graphql-yoga).
Resolvers
Functions that fetch data — from a database, a REST API, a cache, or another microservice.
Data Sources
SQL/NoSQL databases, third-party APIs, internal microservices, or caches like Redis.
Schema Registry
In large orgs, a central place (e.g., Apollo GraphOS) that stores the “combined” schema across teams.
Gateway / Federation Layer
Combines multiple smaller GraphQL services into one unified graph for the client.
Internal Working & Data Flow / Lifecycle
Here is what happens, step by step, from the moment a client sends a GraphQL query to the moment it gets a response:
1. Parsing
The query string is turned into an Abstract Syntax Tree (AST) — a tree structure the server can understand programmatically.
2. Validation
The server checks the AST against the schema: do the requested fields exist? Are the types correct? Are required arguments present?
3. Execution
The server walks the query tree field by field, calling the resolver function assigned to each field.
4. Resolver fan-out
Parent resolvers finish first (e.g., fetch the user), then child resolvers run for nested fields (e.g., fetch each order for that user), which is where repeated database calls can silently multiply.
5. Merging
Results from every resolver are merged back into a single JSON object that mirrors the exact shape of the original query.
6. Response
The final JSON is sent back to the client in a single HTTP response, almost always with a 200 status code — even if part of the data failed (more on this in Chapter 07).
The Main Downside: The N+1 Problem
If someone asks you in an interview “what is a downside of GraphQL?”, the single most common and most important answer is: the N+1 query problem, caused by GraphQL’s resolver-per-field execution model.
6.1 What is the N+1 problem?
Analogy: Imagine you ask a librarian for a list of 10 books, and then for each book you also want to know the author’s biography. A naive librarian goes to the shelf once to get the list of 10 books (1 trip), and then makes a separate trip to look up each author’s biography one at a time (10 more trips) — 11 trips total instead of 2. A smart librarian would instead grab the whole list of 10 authors at once and go on a single, combined trip to fetch all 10 biographies together.
In GraphQL terms: If you query for a list of N users, and each user has a nested field like orders, a naive resolver implementation runs 1 query to get the N users, and then N separate queries — one per user — to get each user’s orders. That’s 1 + N database queries for what should ideally be 2.
query {
users { # 1 query: SELECT * FROM users
name
orders { # N queries: SELECT * FROM orders WHERE user_id = ?
total # ...run once PER user, one at a time
}
}
}
6.2 Why does GraphQL make this worse than REST?
In REST, a backend developer designs each endpoint by hand and typically writes one efficient, joined SQL query for the entire response, because the shape of the response is fixed and known in advance. In GraphQL, the client decides the shape of the response at request time, and resolvers are written independently, field by field, without knowing what else will be requested alongside them. This independence is what makes GraphQL flexible — and it’s exactly what makes N+1 so easy to introduce by accident.
6.3 The standard fix: batching with DataLoader
The industry-standard solution is a pattern called batching and caching within a single request, popularised by Facebook’s DataLoader library. Instead of each resolver immediately querying the database, it queues up the IDs it needs and waits a tiny fraction of a moment; DataLoader then fires one combined query for all the queued IDs together.
DataLoader does not remove the downside — it manages it. It is an extra piece of infrastructure that every resolver author must know about, use correctly, and test. Forgetting to add a DataLoader for a new nested field is one of the most common real-world GraphQL production bugs, and it often only shows up under real traffic, not in local testing with small data sets.
Here is a simplified Java example using a batching approach similar to DataLoader (using the java-dataloader library from graphql-java):
// A resolver WITHOUT batching (causes N+1)
public class OrderResolver {
public List<Order> getOrders(User user) {
// Runs once PER user -> N+1 problem
return orderRepository.findByUserId(user.getId());
}
}
// A resolver WITH batching using DataLoader
public class BatchOrderLoader implements BatchLoader<String, List<Order>> {
private final OrderRepository orderRepository;
public BatchOrderLoader(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public CompletionStage<List<List<Order>>> load(List<String> userIds) {
// Runs ONCE for the whole batch of user IDs
Map<String, List<Order>> ordersByUser =
orderRepository.findByUserIds(userIds); // single SQL: WHERE user_id IN (...)
List<List<Order>> result = userIds.stream()
.map(id -> ordersByUser.getOrDefault(id, List.of()))
.collect(Collectors.toList());
return CompletableFuture.completedFuture(result);
}
}
// Wiring it into a DataLoaderRegistry
DataLoader<String, List<Order>> orderLoader =
DataLoaderFactory.newDataLoader(new BatchOrderLoader(orderRepository));
DataLoaderRegistry registry = new DataLoaderRegistry();
registry.register("orders", orderLoader);
With DataLoader wired up, GraphQL collects all the userIds requested during one “tick” of execution and sends a single WHERE user_id IN (...) query instead of 100 separate ones — turning 101 queries back into 2.
What DataLoader Gives You
- Batches many small queries into one
- Caches results within a single request
- Well-supported across GraphQL server libraries
What It Costs You
- Extra library and mental model every developer must learn
- Must be added manually to every nested resolver — easy to forget
- Debugging batching bugs (stale cache within a request) is non-trivial
More Downsides & Trade-offs of GraphQL
N+1 is the most famous downside, but it is far from the only one. Here is a full, honest list.
7.1 Query complexity abuse (denial-of-service risk)
Because clients can shape queries freely, a malicious or careless client can write a deeply nested query that explodes into an enormous amount of server work — for example, asking for a user’s friends, and each friend’s friends, and each of those friends’ posts, and each post’s comments, several levels deep. This is sometimes called a “Query of Death.” A single request could cost the server what would normally take thousands of REST requests.
query EvilQuery {
user(id: "1") {
friends {
friends {
friends {
posts {
comments {
author { friends { friends { posts { comments { text } } } } }
}
}
}
}
}
}
}
In REST, each endpoint is fixed and its cost is roughly predictable because a developer wrote it. In GraphQL, the client effectively “writes” part of the query at request time, so the server must actively defend itself using query cost analysis, depth limiting, and complexity scoring — extra infrastructure REST rarely needs.
7.2 Caching is much harder
HTTP-level caching (like browser caching, CDN caching, or reverse-proxy caching) works beautifully for REST because REST URLs are unique per resource: GET /users/42 always means the same thing and can be cached under that URL. GraphQL typically uses a single endpoint (POST /graphql) with a different body for every request, so standard HTTP caching by URL simply does not work out of the box. Teams must build custom caching layers (like persisted queries, Apollo’s response cache, or normalized client-side caches like Apollo Client’s InMemoryCache or Relay’s store).
7.3 File uploads are not natively supported
The GraphQL specification is built around JSON-like data — it has no first-class way to send binary files like images or PDFs. Teams have to use unofficial community conventions (like the graphql-multipart-request-spec) or fall back to a separate REST/plain HTTP endpoint just for uploads, meaning your API is no longer “pure GraphQL” for every feature.
7.4 HTTP status codes lose their meaning
In REST, a 404 means “not found,” a 500 means “server error,” a 200 means “success” — the status code carries meaning at a glance. GraphQL almost always returns HTTP 200, even when part of the request failed, and puts errors inside the response body in an errors array instead. This means tools built around HTTP status codes (like some monitoring dashboards, load balancer health checks, or simple curl scripts) become less useful, and your team has to build custom error-parsing logic everywhere.
{
"data": { "user": null },
"errors": [
{
"message": "User not found",
"path": ["user"],
"extensions": { "code": "NOT_FOUND" }
}
]
}
// HTTP status: 200 OK <- even though the request logically failed
7.5 Steeper learning curve and schema governance overhead
Teams must learn SDL, resolvers, the type system, batching patterns, and often a client library like Apollo Client or Relay, which itself has real complexity (normalized caches, fragments, code generation). At company scale, the schema becomes shared infrastructure: many teams contribute fields to one graph, and someone has to govern naming conventions, deprecations, and breaking changes — this is a genuine organisational cost that a collection of independent REST services does not have in the same way.
7.6 Rate limiting and cost prediction are harder
Traditional rate limiting counts “requests per minute.” But one GraphQL request can be cheap or extremely expensive depending on what fields and nesting the client asked for. Simple request-counting rate limiters are not enough; teams need “query cost” based rate limiting, which is more work to build and tune correctly.
7.7 Versioning philosophy shift, not a free lunch
GraphQL encourages “no versioning” — you add new fields and deprecate old ones instead of shipping /v1, /v2 endpoints. This is often marketed as an advantage, but it pushes real complexity into schema design discipline: teams must be very careful about deprecating fields safely, because many different client apps (some very old, on users’ phones, that can’t be force-updated) may still depend on old fields. Managing this well requires strong internal tooling and process — it doesn’t happen automatically.
7.8 Duplicate work and inconsistent client-side patterns
Because GraphQL lets every screen, or even every component within a screen, ask for its own custom slice of data, large front-end codebases can end up with dozens of nearly identical but slightly different queries scattered across the application — one component asks for user { name email }, another asks for user { name email avatar }, and a third asks for user { name } only. Without discipline (like shared GraphQL fragments and strong code review habits), this duplication makes the codebase harder to maintain than a smaller, curated set of REST endpoints would have been, and it can quietly reintroduce inefficiency at the network level if similar-but-not-identical queries can’t be cached or batched together.
7.9 Tooling and ecosystem lock-in considerations
Getting the full benefit of GraphQL in production usually means adopting a broader ecosystem: a client library like Apollo Client or Relay, a server framework, a federation/gateway tool, a schema registry, and often a code-generation pipeline that turns your schema into typed client code. Each of these tools has its own learning curve, versioning cadence, and occasional breaking changes. Compared to REST, where you can often get very far with just a plain HTTP client and basic JSON parsing, GraphQL’s practical ecosystem footprint in a serious production app is noticeably larger, and migrating away from a specific GraphQL client library later on can be a substantial undertaking.
Genuine Advantages (for balance)
- No more over-fetching or under-fetching
- Strong typed schema catches bugs early
- Great developer experience with introspection and tooling (e.g., GraphiQL)
- One request can replace several REST round trips
Downsides Covered in This Guide
- N+1 query problem needs active management (DataLoader)
- Harder HTTP-level caching
- Query complexity / DoS risk needs extra defenses
- No native file upload support
- HTTP status codes lose meaning; custom error handling needed
- Steeper learning curve and schema governance cost
- Harder rate limiting
Performance & Scalability
Beyond N+1, there are broader performance considerations that come from GraphQL’s flexible nature.
8.1 Unpredictable server load
Because query shape is decided by the client, the same endpoint (/graphql) can receive requests that range from trivially cheap to extremely expensive. This makes capacity planning harder than for REST, where each endpoint’s typical cost is well understood in advance.
8.2 Query complexity analysis
Production GraphQL servers assign a numeric “cost” to each field (based on things like expected list size and nesting depth) and reject queries above a threshold before execution even begins. Libraries like graphql-cost-analysis (Node.js) or built-in instrumentation in GraphQL Java help implement this, but someone on the team has to design and tune these cost rules — it’s not automatic.
8.3 Persisted queries
To reduce parsing cost and enable safer caching, many teams use persisted queries — the client sends a short hash instead of the full query text, and the server looks up the pre-approved query by that hash. This adds a build step and a registry to maintain, which is more moving parts than a plain REST deployment.
Caching Challenges (Deep Dive)
Let’s go deeper on caching, since it is one of the most commonly cited GraphQL downsides in real interviews.
9.1 Why REST caching is simple
REST resources map to URLs, and HTTP has decades of caching infrastructure built around URLs: browsers cache them, CDNs like Cloudflare or Akamai cache them, and reverse proxies like Varnish cache them — all for free, using standard headers like Cache-Control and ETag.
9.2 Why GraphQL breaks this model
A single GraphQL endpoint receiving POST requests with varying bodies looks identical to CDNs and browsers from a URL standpoint — there’s nothing to key the cache on by default. Two very different queries hit the exact same URL.
9.3 How teams work around it
Normalized client caches
Apollo Client and Relay cache individual objects by ID on the client, so re-fetching the same object elsewhere in the app is instant — but this only helps the client, not shared server-side caching.
Persisted queries + GET
Turning common queries into GET requests with a fixed hash lets CDNs cache them like normal REST URLs.
Server-side response caching
Tools like Apollo Server’s response cache or a custom Redis layer cache resolver-level or query-level results, keyed by query + variables.
Automatic Persisted Queries (APQ)
A hybrid approach where the client sends a hash first, and only sends the full query text if the server doesn’t recognise the hash yet.
None of these are “off the shelf” the way REST + CDN caching is. Every one of them is an extra system a team must design, build, and operate — which is a real, ongoing engineering cost that should be weighed honestly against GraphQL’s flexibility benefits.
9.4 A concrete analogy for caching pain
Think of a library that gives every visitor a personal, custom-bound book made only of the exact chapters they asked for, stitched together on the spot. It is a wonderful, tailored experience for the visitor. But now imagine the librarian trying to keep a shelf of “popular pre-made books” ready to hand out quickly to save time — it’s nearly impossible, because almost every visitor asks for a slightly different combination of chapters. A traditional library, where every visitor takes the exact same pre-printed book off the shelf (like a fixed REST URL), is trivial to prepare copies of in advance. This is the essence of why GraphQL caching requires new invention rather than reusing decades-old HTTP caching wisdom.
Advanced: Execution Model, Complexity & Concurrency
This section goes one level deeper into the computer-science mechanics behind GraphQL’s downsides — useful for interview preparation and for engineers who want to reason about performance precisely rather than just intuitively.
10.1 The query as a tree, and why traversal order matters
A GraphQL query, once parsed, becomes a tree data structure (the AST mentioned in Chapter 05). The execution engine walks this tree, generally in a manner similar to a breadth-first traversal across each “level” of the tree, so that all resolvers at the same depth can be batched together before moving one level deeper. This is exactly why DataLoader works: because the engine processes an entire level of “sibling” fields together, it can collect all the IDs needed for that level and issue one batched request, instead of resolving one branch of the tree completely before starting the next.
10.2 Time complexity of naive vs batched resolution
If a query returns a list of N top-level items, and each item has a nested list resolver that is not batched, the naive cost is O(N) additional database round trips on top of the first one — so O(N) total database calls for one logical “get list with details” operation. If the query is nested two levels deep and each level is also unbatched, the cost becomes O(N × M), where M is the average size of the second-level list. This can compound quickly: a query 3 levels deep with lists of size 20 at each level could theoretically trigger on the order of 20 × 20 × 20 = 8,000 database calls from a single incoming request if nothing is batched. With DataLoader-style batching, this collapses to roughly O(depth) database calls — one batched call per tree level — regardless of how wide each level is.
| Approach | DB calls for 3-level nested query, ~20 items per level |
|---|---|
| Naive, unbatched resolvers | Up to ~8,000 (worst case, compounding) |
| Batched with DataLoader | ~3 (one per tree depth level) |
10.3 Concurrency inside a single GraphQL request
Modern GraphQL server implementations (including GraphQL Java and Node.js-based servers) execute independent resolvers concurrently, not one after another, using asynchronous execution (futures/promises in Java, Promises in JavaScript). This means that while one resolver is waiting on a database call, another resolver for a sibling field can start its own work at the same time. This concurrency is a genuine strength for latency — but it also introduces classic concurrency concerns that REST endpoints, which are typically implemented as single, sequential handler functions, don’t usually have to think about as deeply: race conditions if two resolvers mutate shared in-request state, the need for thread-safe batching structures inside DataLoader implementations, and careful handling of partial failures when several concurrent resolvers are in flight and one of them throws an exception.
10.4 Reliability in federated / distributed GraphQL
When GraphQL is deployed as a federated gateway calling multiple backend subgraph services (Chapter 13), it effectively becomes a distributed system, and classic distributed-systems trade-offs apply. If the Reviews subgraph is slow or down, should the gateway wait indefinitely, time out and return partial data, or fail the whole request? Most production gateways choose to return partial data with an error for the failed subgraph (a graceful degradation strategy), which lines up with GraphQL’s philosophy of partial responses (seen earlier in the errors array), but it means client applications must always be written defensively to handle partial data, since a “successful” 200 response might still be missing pieces. This is conceptually related to the availability side of the CAP theorem: a federated GraphQL gateway usually favours availability (serve what it can) over strict consistency (block until everything is guaranteed complete and correct).
If asked “how does GraphQL handle a failing downstream service,” a strong answer is: “GraphQL returns partial data alongside a structured error for the failed field, favouring availability over an all-or-nothing response — but this requires every client to defensively null-check fields instead of assuming a 200 response is complete.”
Security Downsides
GraphQL’s flexibility, which is its biggest strength, is also the source of most of its security downsides.
Introspection leakage
By default, GraphQL lets clients ask the server “what can I query?” (introspection). If left enabled in production, attackers can map your entire data model, including fields you forgot were sensitive.
Deeply nested / recursive queries
As shown in Chapter 07.1, an attacker can craft a query that recursively expands and consumes huge server resources — a form of denial-of-service unique to flexible query languages.
Batching attacks
Multiple operations can be sent in a single HTTP request, which attackers can use to bypass simple per-request rate limits (e.g., brute-forcing a login mutation many times in one call).
Field-level authorization complexity
Since any field can be requested in any combination, authorization checks often need to happen per-field, not just per-endpoint, which is more code and more places to make a mistake.
Disable introspection in production, set maximum query depth and complexity limits, add per-operation rate limiting inside batched requests, and implement field-level authorization in resolvers (not just at the top of the schema). None of this is automatic — GraphQL gives you the flexibility, but the safety rails are your responsibility to build.
11.1 A concrete field-level authorization example
In REST, you often protect an entire endpoint with one guard, like a middleware checking a JWT token before GET /admin/users runs at all. In GraphQL, because a single query can mix public and private fields from the same type, authorization frequently has to live inside individual resolvers instead.
public class UserResolver {
public String getEmail(User user, DataFetchingEnvironment env) {
AuthContext ctx = env.getContext();
// Field-level check: only the user themself or an admin can see the email
if (!ctx.getCurrentUserId().equals(user.getId()) && !ctx.isAdmin()) {
throw new UnauthorizedFieldException("email");
}
return user.getEmail();
}
public String getPublicDisplayName(User user) {
// No check needed - this field is intentionally public
return user.getDisplayName();
}
}
Notice that two fields on the exact same User type need two completely different authorization rules. Multiply this across a schema with hundreds of fields, contributed by many different teams over time, and it becomes clear why field-level authorization is considered a real, ongoing engineering cost of GraphQL rather than a one-time setup task.
11.2 Depth and complexity limiting in practice
// Example configuration idea using graphql-java's MaxQueryDepthInstrumentation
Instrumentation depthLimit = new MaxQueryDepthInstrumentation(10);
Instrumentation complexityLimit =
new MaxQueryComplexityInstrumentation(5000, new SimpleFieldComplexityCalculator());
GraphQL graphQL = GraphQL.newGraphQL(schema)
.instrumentation(new ChainedInstrumentation(List.of(depthLimit, complexityLimit)))
.build();
Here, any query nested deeper than 10 levels, or scored above a complexity budget of 5000 points, is rejected before a single resolver runs. Choosing the right numbers for depth and complexity is itself non-trivial: too strict, and legitimate client screens start failing; too loose, and the “Query of Death” risk from Chapter 07.1 remains open.
Monitoring, Logging & Debugging
Because every request goes to the same URL with different bodies, and because HTTP status codes stay at 200 even on partial failure (Chapter 07.4), classic HTTP-based monitoring tools are much less useful for GraphQL out of the box.
- APM tools need GraphQL-aware instrumentation (e.g., Apollo Studio, or OpenTelemetry GraphQL instrumentation) to break down performance by operation name and individual field, not just by URL.
- Field-level tracing is needed to find which specific nested resolver is slow — a generic “this endpoint is slow” alert isn’t precise enough when one endpoint handles thousands of different query shapes.
- Error grouping requires parsing the
errorsarray in each response body rather than relying on status codes, so log pipelines need custom parsing logic.
Deployment & Microservices Complexity
As companies grow, they often want multiple teams to own different parts of the graph. This leads to schema federation (e.g., Apollo Federation, GraphQL Mesh), where several GraphQL services are combined into a single unified graph behind a gateway.
Federation adds schema composition checks to your CI/CD pipeline (to make sure subgraph changes don’t break the combined graph), a gateway that must stay in sync with every subgraph, and cross-team coordination for shared types. This is real, ongoing engineering investment that a simpler REST microservices setup often avoids.
13.1 New failure modes introduced by the gateway
In a plain REST microservices setup, if the Orders service goes down, only calls that specifically need Orders data fail — everything else keeps working normally, and each client already expects to call different services independently. In a federated GraphQL setup, the gateway is a single, shared front door for the entire graph. If the gateway itself has a bug, is slow, or runs out of memory composing very large responses, it can degrade or fail requests that don’t even touch the failing subgraph, simply because they all pass through the same component. This makes the gateway one of the highest-value targets for redundancy, load testing, and careful capacity planning in a GraphQL-based architecture.
13.2 Schema composition and breaking-change detection
Before a federated schema change ships, tools like Apollo’s Rover CLI or GraphQL Inspector typically run a “composition check” in CI: they simulate combining every subgraph’s schema together and fail the build if two subgraphs define conflicting types, or if a change would break a query that real client applications currently depend on. Setting this up correctly, and keeping it fast enough not to slow down every team’s deploy pipeline, is itself a piece of platform engineering work that a company must invest in — it does not come for free just by choosing GraphQL.
13.3 Deployment coordination across teams
Because subgraphs are independently owned but combined into one graph, teams must agree on shared conventions: how IDs are formatted, how pagination works, how errors are shaped, and how deprecations are communicated. Without this coordination, the unified graph can end up feeling inconsistent to client developers, even though each individual subgraph might be well built on its own. Many organisations solve this by creating a small platform team whose entire job is to maintain graph-wide standards and tooling — an organisational cost that is easy to underestimate before adopting GraphQL at scale.
Design Patterns & Anti-patterns
Recommended patterns
DataLoader everywhere
Wrap every list-producing or nested resolver in a batched loader to avoid N+1 by default, not as an afterthought.
Query cost limiting
Assign complexity scores to fields and reject overly expensive queries before execution.
Schema-first design
Design the schema collaboratively before writing resolvers, so the contract is stable and intentional.
Persisted queries
Lock production clients to a known, pre-approved set of queries to reduce both cost and attack surface.
Common anti-patterns
The “God Query” schema
Exposing your entire raw database schema as GraphQL types, leading to giant, hard-to-secure, hard-to-cache graphs.
Resolver-per-database-call, no batching
Writing resolvers that always hit the database directly with no DataLoader — a guaranteed N+1 problem in production.
Leaving introspection on in production
Handing attackers a complete map of your data model for free.
No depth or complexity limits
Leaving the server open to the “Query of Death” denial-of-service pattern from Chapter 07.1.
Best Practices & Common Mistakes
1. Always batch nested resolvers
Treat DataLoader (or an equivalent) as mandatory infrastructure, not an optional optimisation. Make it part of your team’s code review checklist: any new resolver that fetches related data by an ID coming from a parent object should be reviewed specifically for N+1 risk before merging.
2. Set query depth and complexity limits
Do this from day one, not after the first production incident. Start conservative and loosen the limits based on real client usage data, rather than guessing a generous number upfront and hoping nobody abuses it.
3. Disable introspection in production
Unless you have a specific, controlled reason to keep it on (like an internal developer portal behind authentication). Many teams keep introspection enabled in staging and development environments only.
4. Design field-level authorization into your resolvers
Don’t assume a single top-level check is enough. Write automated tests that specifically try to fetch sensitive fields as an unauthorised user, not just tests that check the “happy path” works for authorised users.
5. Invest early in tracing and per-field monitoring
Retrofitting observability after a graph gets large is painful, because you’ll have hundreds of fields to instrument retroactively instead of building tracing habits in from the first resolver.
6. Use persisted queries in production clients
To reduce both cost surface and unpredictable query shapes hitting your server, and to enable CDN-level caching of common, pre-approved queries.
7. Plan your schema evolution and deprecation process
Before your first breaking change is needed, since “no versioning” requires discipline, not less work. Use the
@deprecateddirective, communicate timelines clearly to client teams, and monitor actual field usage before removing anything old clients might still depend on.
Real-World & Industry Examples
These examples matter because they show the downsides are not theoretical — the very companies that popularised GraphQL had to build entire extra systems (DataLoader, cost-based rate limiting, federation gateways) specifically to manage its trade-offs in production.
16.1 A closer look: Shopify’s cost-based rate limiting
Shopify’s Admin GraphQL API assigns every field a “cost” in points, and every app is given a bucket of points that refills at a steady rate over time, similar to how a leaky-bucket rate limiter works in classic networking. A cheap query might cost 1 point, while a deeply nested query requesting many connected objects could cost hundreds of points in a single call. This directly mirrors the query-complexity downside discussed in Chapters 07.1 and 08.2 — Shopify had to build this system specifically because a simple “100 requests per minute” REST-style limiter would not have protected their servers from expensive GraphQL queries.
16.2 A closer look: GitHub’s dual-API strategy
GitHub deliberately kept its REST API (v3) running alongside its newer GraphQL API (v4) instead of fully replacing it. Certain operations, like simple webhooks or straightforward single-resource lookups, remain easier and cheaper to expose through REST, while GraphQL is used where clients (like GitHub’s own web UI or third-party integrations) need to assemble complex, custom views combining repositories, issues, pull requests, and users in one round trip. This dual-strategy approach is itself evidence of a downside: GraphQL is not automatically the better choice for every single endpoint, and mature companies often run both models side by side rather than fully committing to one.
16.3 A closer look: Netflix’s federated Domain Graph Service
Netflix built its own layer, called the Domain Graph Service (DGS) framework (open-sourced for the Java/Spring ecosystem), specifically to make it easier for individual backend teams to expose GraphQL subgraphs that a central gateway federates together. Netflix engineers have written publicly about the operational discipline required to keep hundreds of engineers across many teams contributing safely to one combined graph — including schema linting, automated breaking-change detection in CI, and gateway performance monitoring — all extra infrastructure investment tied directly to the federation downside covered in Chapter 13.
FAQ
What is THE single biggest downside of GraphQL?
Most engineers and most interview answers point to the N+1 query problem: because resolvers run independently per field, naive implementations issue far more database queries than necessary, requiring extra tooling like DataLoader to fix.
Is GraphQL slower than REST?
Not inherently — but it is easier to accidentally build something slow with GraphQL because of N+1 and unpredictable query shapes, unless you actively add batching, caching, and cost limits.
Does GraphQL replace the need for REST entirely?
No. Many production systems use both — REST for simple, cacheable, or file-upload-heavy operations, and GraphQL for flexible, aggregation-heavy client screens.
Is caching impossible in GraphQL?
No, but it is harder and requires deliberate engineering (persisted queries, normalized client caches, custom server response caches) instead of the free HTTP-caching that REST URLs get automatically.
Is GraphQL less secure than REST?
Not less secure by design, but it has a different, larger set of things you must actively configure (introspection, depth limits, cost limits, field-level auth) that REST APIs don’t typically need to think about in the same way.
Can DataLoader fully eliminate the N+1 problem?
It eliminates the excess database round trips within a single request by batching, but it does not eliminate the underlying design responsibility. Every new nested resolver added to the schema must be deliberately wired up to a batched loader, or the N+1 pattern silently reappears for that specific field.
Why do GraphQL responses always return HTTP 200?
Because the GraphQL specification treats a request as “successfully processed by the GraphQL engine” even if some fields inside it failed to resolve. The engine’s job is to always try to return as much data as it safely can, with structured errors describing exactly which fields failed and why, rather than failing the entire response for one bad field.
Does using GraphQL mean you no longer need API documentation?
The schema itself acts as a form of living documentation through introspection, and tools like GraphiQL or Apollo Sandbox make it browsable. However, teams still need supplementary docs to explain business rules, deprecation timelines, and correct usage patterns — the schema alone tells you the shape of data, not the “why” behind it.
Is it a bad idea to use GraphQL for a small project?
For a small project with a handful of simple, well-known screens, plain REST (or even simpler, direct database access patterns) is often faster to build and easier to operate, since you avoid the schema governance, batching, and caching overhead GraphQL requires. GraphQL tends to pay off most clearly once you have many client screens with very different data needs, or multiple client platforms (web, iOS, Android) fetching overlapping but not identical data.
Summary & Key Takeaways
GraphQL exists to fix two real REST problems: over-fetching and under-fetching, by letting clients ask for exactly the data shape they need in a single request. That flexibility is genuinely powerful — but it moves a lot of hidden complexity onto the server: the N+1 query problem, harder HTTP-level caching, higher risk of expensive or malicious queries, loss of meaningful HTTP status codes, no native file upload support, a steeper learning curve, and real organisational overhead in schema governance and federation at scale. None of these downsides are permanent blockers — they are well-understood, solvable engineering problems (DataLoader, cost analysis, persisted queries, field-level auth, per-field tracing) — but they are not free, and a team adopting GraphQL should budget real engineering time to manage them properly.
When an interviewer asks “what is a downside of GraphQL?”, the strongest one-line answer is: “It replaces REST’s over-fetching / under-fetching problem with a new set of server-side responsibilities — N+1 batching, custom caching, query-cost limits, field-level authorization, and per-field observability — each of which is manageable but none of which comes for free.”
Key Takeaways
- GraphQL solves over-fetching and under-fetching by letting clients request an exact, custom shape of data in one call.
- The most cited GraphQL downside is the N+1 query problem, caused by independent, per-field resolver execution — fixed in production using batching tools like DataLoader.
- GraphQL’s single-endpoint, flexible-query design breaks standard HTTP/CDN caching, requiring custom caching strategies like persisted queries and normalized client caches.
- Because clients can shape arbitrarily deep and expensive queries, GraphQL servers need active defenses: query depth limits, cost-based complexity analysis, and cost-based rate limiting — not just simple per-request rate limits.
- HTTP status codes stay at 200 even on partial failure, so error handling, monitoring, and logging all need GraphQL-aware tooling instead of standard HTTP tooling.
- At company scale, a shared GraphQL schema becomes real infrastructure requiring governance, and federation across teams adds a gateway as a new critical operational component.
- These downsides are all manageable with mature, well-known solutions — but none of them are automatic; they require deliberate engineering investment.