Gartner's 2026 Hype Cycle for APIs places GraphQL in the Trough of Disillusionment. Apollo, the company with the most to lose from that placement, published a blog post agreeing with it. That is roughly where the technology sits now: past the phase where adopting it was a statement, into the phase where teams ask whether it solves a problem they actually have and whether they can operate it without misery.
Both of those questions have real answers. GraphQL solves a specific structural problem very well, and it charges an operational price that REST does not. This is an attempt to price both honestly - schema design, resolvers, the N+1 problem, caching, federation, security, complexity limits - and to say plainly where REST is the simpler choice.
The one problem GraphQL actually solves
With REST, the shape of a response is decided by the endpoint. With GraphQL, it is decided by the caller. Everything else - the type system, the tooling, the introspection - follows from moving that decision.
That matters when you have many different clients with different needs against the same data. A mobile app on a slow connection wants three fields. A web dashboard wants forty across six entities. A partner integration wants something else again. In REST you serve that with endpoint proliferation, sparse fieldsets, or an endpoint per screen. In GraphQL the client asks.
# REST: three round trips, and each returns more than the screen needs
GET /users/42
GET /users/42/orders?limit=5
GET /orders/874/items
# GraphQL: one round trip, exactly the fields
query OrderScreen {
user(id: "42") {
name
orders(first: 5) {
nodes {
id
totalCents
items { sku quantity }
}
}
}
}Schema design
The schema is the product. It outlives your ORM, your framework and probably your current backend language, and unlike a REST endpoint it is a single global namespace that every team contributes to. Design decisions here are expensive to reverse.
Model the domain, not the database
# Weak: a table with a GraphQL syntax highlight
type Order {
id: ID!
customer_id: Int!
status_code: Int!
created_at: String!
}
# Better: the domain, with the relationships the client actually traverses
type Order {
id: ID!
customer: Customer!
status: OrderStatus!
total: Money!
placedAt: DateTime!
items(first: Int, after: String): OrderItemConnection!
}
enum OrderStatus { PENDING PAID SHIPPED CANCELLED }
type Money {
amountCents: Int!
currency: Currency!
}total_cents: Int means adding a formatted display value later is additive. Scalars are hard to extend; objects are easy.Nullability is a contract, not a formality
GraphQL's null propagation is unforgiving and widely misunderstood. If a non-null field resolves to null or errors, the null bubbles up to the nearest nullable parent. Mark a whole path non-null and one failing leaf can blank out an entire response.
type Query {
# If this errors, the client gets data: null for the whole query
orders: [Order!]!
}
type Order {
id: ID! # genuinely never null - fine
customer: Customer! # requires a second service. Is it REALLY never null?
recommendations: [Product!] # nullable list: a failure here degrades gracefully
}Conventions worth adopting on day one
- Relay connections for every list.
edges/node/pageInfolooks verbose, but it is the only pagination shape the ecosystem's clients and caches understand, and retrofitting it is breaking. - A single input object per mutation.
createOrder(input: CreateOrderInput!)lets you add fields without changing the signature. - Mutation payloads as types, not scalars. Return
CreateOrderPayload { order, userErrors }so you can add fields and report business failures without an HTTP error. - Expected failures belong in the schema. Validation failures are data, not exceptions. Reserve the
errorsarray for things that genuinely went wrong. - Custom scalars for constrained values -
DateTime,EmailAddress,URL. Validation moves to one place and the schema documents itself. - Never expose an internal enum directly. Your database's
status_code = 3should not become a GraphQL value you can never change.
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
}
type CreateOrderPayload {
order: Order
userErrors: [UserError!]! # expected failures, typed and in the data
}
type UserError {
field: [String!] # path into the input
code: UserErrorCode!
message: String!
}errors array that also carries genuine server faults.Evolution: deprecate, do not version
GraphQL's official answer to versioning is that you should not need it. Adding fields is safe because clients only receive what they asked for. Removing them is handled by @deprecated plus usage tracking.
type Order {
totalCents: Int! @deprecated(reason: "Use `total`. Removal after 2026-12-01.")
total: Money!
}Resolvers and the execution model
A resolver is a function that returns one field. The engine walks the query tree, calls a resolver per field per object, and assembles the result. Sibling fields execute concurrently; a field's children wait for their parent.
const resolvers = {
Query: {
order: (_, { id }, ctx) => ctx.loaders.order.load(id),
},
Order: {
// Called once per Order in the result set
customer: (order, _, ctx) => ctx.loaders.customer.load(order.customerId),
total: (order) => ({ amountCents: order.totalCents, currency: order.currency }),
items: (order, args, ctx) => ctx.loaders.itemsByOrder.load(order.id),
},
}Two properties of this model cause most of the trouble. First, a resolver has no idea what the rest of the query is doing, so it cannot optimise across siblings. Second, a resolver for a nested field runs once per parent object - which is where N+1 comes from.
The N+1 problem
This is the defining operational hazard of GraphQL, and it is structural rather than a bug you can fix once.
query {
orders(first: 50) { # 1 query: fetch 50 orders
nodes {
customer { name } # 50 queries: one per order
items { # 50 more
product { name } # and one per item, so potentially hundreds
}
}
}
}DataLoader
The standard mitigation batches and deduplicates within a single tick of the event loop. Every .load(id) call in the same tick collects into one batch function call.
import DataLoader from 'dataloader'
export function createLoaders(db: Database) {
return {
customer: new DataLoader<string, Customer>(async (ids) => {
const rows = await db.customer.findMany({ where: { id: { in: [...ids] } } })
const byId = new Map(rows.map((r) => [r.id, r]))
// CRITICAL: return in the same order as `ids`, with a slot for every id
return ids.map((id) => byId.get(id) ?? null)
}),
itemsByOrder: new DataLoader<string, OrderItem[]>(async (orderIds) => {
const rows = await db.orderItem.findMany({
where: { orderId: { in: [...orderIds] } },
})
const grouped = new Map<string, OrderItem[]>()
for (const row of rows) {
const list = grouped.get(row.orderId) ?? []
list.push(row)
grouped.set(row.orderId, list)
}
return orderIds.map((id) => grouped.get(id) ?? [])
}),
}
}The other approaches, and their limits
| Approach | How it works | Where it breaks down |
|---|---|---|
| DataLoader | Batch and dedupe per request tick | Still N queries for N distinct entity types; needs discipline everywhere |
| Look-ahead / projection | Inspect the query AST and build one optimised query | Complex to write, and fragile as the schema grows |
| Query-to-SQL compilers | Hasura, PostGraphile - compile the whole GraphQL query to one SQL statement | Ties your API shape closely to your schema; less freedom in resolvers |
| Aggressive caching | Serve from Redis or an in-memory cache | Moves the problem; invalidation is now yours |
The uncomfortable truth is that GraphQL makes it trivially easy for a client to write an expensive query and gives the server limited tools to refuse. Performance work moves from "optimise this endpoint" to "make every possible traversal of the graph acceptable" - a much larger surface.
Caching, where REST wins outright
This is the clearest structural disadvantage and it is worth being blunt about. REST gets HTTP caching for free. GraphQL, as normally deployed, gets none of it.
| Layer | REST | GraphQL |
|---|---|---|
| Browser cache | Automatic via URL, ETag, Cache-Control | None - everything is a POST to one URL |
| CDN cache | Works out of the box | Requires GET plus persisted queries, or a GraphQL-aware edge |
| Conditional requests | If-None-Match → 304 | No standard mechanism |
| Client cache | URL-keyed, simple | Normalised entity cache - powerful, and a real dependency |
| Cache invalidation | Per URL | Per entity, in the client cache, by you |
The mitigations exist and each carries a cost. Automatic persisted queries send a hash instead of a document, which allows GET and therefore CDN caching - at the price of a registry step in your build and deploy. Response caching at the router or server level, keyed by query hash and variables, works but you own the invalidation entirely. @cacheControl hints let the schema declare per-field TTLs which the gateway composes into a response-level policy.
type Product @cacheControl(maxAge: 300) {
id: ID!
name: String!
price: Money! @cacheControl(maxAge: 30)
stock: Int! @cacheControl(maxAge: 0, scope: PRIVATE)
}Where GraphQL claws some of this back is on the client. Apollo Client, Relay and urql maintain a normalised cache keyed by entity ID, so a product fetched in one query is reused by another without a request. That is genuinely better than URL-keyed caching - and it is a substantial client-side dependency you now own, with its own consistency bugs.
Security and complexity limits
A REST endpoint has a fixed cost you can measure. A GraphQL endpoint's cost is chosen by the caller, which makes denial of service a design concern rather than an afterthought.
The attack, in four lines
query Bomb {
orders { customer { orders { customer { orders { customer {
orders { customer { name }
} } } } } } }
}The controls, in the order you should add them
| Control | What it stops |
|---|---|
| Depth limiting | Deeply nested recursive queries. Cheap to add, blunt but effective |
| Complexity / cost analysis | Wide queries that are shallow. Assign a cost per field and reject above a budget |
| Pagination limits | first: 10000. Enforce a maximum in the resolver, not just the docs |
| Query timeouts | Anything that slipped through the static checks |
| Persisted queries (allowlist) | Everything above, at once - only pre-registered documents run |
| Introspection control | Casual schema enumeration. Note this is obscurity, not security |
| Rate limiting by cost | Per-request limits that ignore how expensive each request is |
| Batching limits | Array-of-operations requests used to multiply any of the above |
import { createYoga } from 'graphql-yoga'
import { costLimitPlugin, maxDepthPlugin } from '@escape.tech/graphql-armor'
const yoga = createYoga({
schema,
plugins: [
maxDepthPlugin({ n: 8 }),
costLimitPlugin({ maxCost: 5000, objectCost: 2, scalarCost: 1, depthCostFactor: 1.5 }),
],
context: async ({ request }) => ({
user: await authenticate(request),
loaders: createLoaders(db), // per request
}),
})Authorisation belongs in resolvers, not the gateway
There is no URL to protect. A single request can touch dozens of types across several services, each with different rules. Authorisation has to be enforced at the point the data is resolved.
const resolvers = {
Order: {
// Field-level check - this runs for every Order in the result
internalNotes: (order, _, ctx) => {
if (ctx.user.role !== 'staff') return null
return order.internalNotes
},
},
Query: {
order: async (_, { id }, ctx) => {
const order = await ctx.loaders.order.load(id)
// Scope by owner, not just by authentication
if (!order || order.customerId !== ctx.user.customerId) return null
return order
},
},
}- Return
nullrather than an error for unauthorised fields where existence itself is sensitive - the same reasoning behind returning 404 instead of 403 in REST. - Disable introspection in production if you have no public consumers, while remembering that anyone with your client bundle can reconstruct the schema anyway.
- Turn off field suggestions ("Did you mean…") in production - they leak schema shape even with introspection disabled.
- Cap batched operation arrays, or a single request multiplies past every other limit you set.
- Log the operation name and query hash on every request. Without them your traces show one endpoint and tell you nothing.
Federation
Federation composes several independently-owned GraphQL services into one schema. Each team owns a subgraph; a router plans and executes queries across them. Gartner's 2026 guidance identifies it as the preferred approach at large scale, over the older schema-stitching model.
# Orders subgraph - owns Order, references Customer
type Order @key(fields: "id") {
id: ID!
total: Money!
customer: Customer!
}
type Customer @key(fields: "id") {
id: ID! # just the key; this subgraph knows nothing else
}
# Customers subgraph - owns the rest of Customer
type Customer @key(fields: "id") {
id: ID!
name: String!
email: EmailAddress!
}@key directive is the join. The router fetches orders from one service, collects the customer IDs, and resolves them from the other in a second call.What it buys and what it costs
| Buys you | Costs you |
|---|---|
| Team autonomy - deploy a subgraph without coordinating | A router to run, scale, monitor and upgrade |
| One graph for every client, across service boundaries | Schema composition as a build step that can fail |
| Incremental adoption - wrap existing services | Distributed tracing becomes mandatory, not optional |
| Governed access to data across systems | Cross-subgraph N+1: the router's own fan-out |
| Type ownership that maps to team ownership | Naming and convention governance across teams |
Router choice is a real decision now. Apollo's Router is Rust, the reference implementation of the Federation spec, and the commercial default. Hive Gateway is the open-source alternative implementing Federation v2 with no usage limits. WunderGraph Cosmo takes a broader gateway approach across GraphQL, REST and gRPC. All three are production-viable; the differences are governance, licensing and how much of the surrounding platform you want.
The operational cost, itemised
This is the part sales material skips, and it is what the Trough of Disillusionment is made of. None of these are dealbreakers. All of them are work that REST does not require.
| Cost | What it means in practice |
|---|---|
| Observability rework | Every request is POST /graphql with status 200. Your existing dashboards show one endpoint. You need operation-name and per-field tracing before you can debug anything |
| Error handling | Partial success is normal - data and errors together, status 200. Retry logic, alerting and SLOs all need rewriting around that |
| N+1 vigilance | Every new resolver is a potential N+1. This needs to be a review checklist item forever, not a one-time fix |
| Cost limits and tuning | Depth and complexity budgets need measuring, setting and revisiting as the schema grows |
| Caching infrastructure | You are rebuilding what HTTP gave REST for free |
| Schema governance | One global namespace across teams. Without a review process, names collide and conventions drift |
| Field usage analytics | Required before you can ever remove a field. This is a platform capability, not a library |
| Client library weight | Apollo Client or Relay is a substantial dependency with its own cache semantics to learn |
| Specialist knowledge | Hiring for GraphQL experience is harder than hiring for REST, and the failure modes are less familiar |
Gartner's own note on the 2026 placement is worth quoting in substance: GraphQL's potential for AI and agentic workflows has not translated into increased adoption, because it has limited penetration in the key agentic use cases. MCP and agent-oriented protocols arrived with a more legible connection to that work. Apollo's response - that a governed graph gives agents a coherent surface over systems not designed for them - is a reasonable argument, and it is an argument rather than an observed trend.
Where GraphQL still wins
- Many heterogeneous clients. Web, iOS, Android, partners, internal tools - all against the same data, all needing different shapes. This is the original case and it is still the strongest.
- Mobile on poor networks. One round trip instead of four, with no over-fetching, is a real and measurable user-facing win.
- Deeply relational data. Anything where the client naturally traverses - social graphs, org charts, catalogues with facets, project hierarchies.
- Rapidly iterating frontends. New screen, new query, no backend ticket. That decoupling is worth a lot when the frontend team is larger or faster-moving than the backend team.
- A public API with diverse consumers. GitHub recommends its GraphQL API over the REST one for new integrations, precisely because consumer needs vary so widely.
- Federated organisations. Many teams, many services, one surface that clients experience as coherent.
- Aggregation over legacy systems. A schema over a mainframe or an ageing service estate gives you a modern contract without touching what is underneath.
Where REST is simply simpler
- One client, one backend. The problem GraphQL solves does not exist. You are paying setup cost for a benefit you cannot collect.
- Cache-heavy read workloads. Public content, catalogues, documentation. HTTP caching at the CDN is free, effective, and something you would be rebuilding.
- File uploads and downloads. GraphQL has no native binary story. Multipart specs exist and are awkward; a
POSTto a REST endpoint is not. - Simple CRUD. A resource with six fields does not need a type system, a resolver layer and a client cache.
- Webhooks and machine-to-machine callbacks. The receiver wants a fixed, documented payload - flexibility is a liability here.
- Small teams under time pressure. Every hour spent on cost limits and loader plumbing is an hour not spent on the product.
- Predictable-load public APIs. With REST you know what each endpoint costs. With GraphQL your consumers decide, and you enforce that with machinery you must build.
- When your team does not know GraphQL. The failure modes - N+1, cache invalidation, null propagation - are unfamiliar and hit in production rather than in review.
The pattern that actually won
The interesting 2026 finding is not that one replaced the other. It is that the backend-for-frontend shape became the common enterprise model: REST or gRPC between internal services, GraphQL at the edge as the client-facing aggregation layer. Netflix, GitHub, Shopify and others run some version of this.
Clients (web, iOS, Android, partners)
│
▼
GraphQL gateway / router ← client-shaped, one round trip
│ │ │
▼ ▼ ▼
Orders Catalog Identity ← REST or gRPC, cacheable,
service service service simple, independently ownedIt also gives you an incremental adoption path. Put a GraphQL layer over existing REST services, serve one client from it, measure whether the round-trip and payload savings justify the operational cost, and expand or retreat on evidence rather than conviction.
The other options
| Option | It wins on | Consider it when |
|---|---|---|
| REST + OpenAPI | Caching, simplicity, tooling ubiquity, predictable cost | Most APIs, most of the time |
| tRPC | End-to-end types with no schema language and no codegen step | TypeScript on both ends, one team, no third-party consumers |
| gRPC | Throughput, streaming, strict contracts | Service-to-service, especially polyglot and high-volume |
| GraphQL | Client-shaped responses across heterogeneous consumers | Many different clients against deeply relational data |
Mistakes that keep recurring
| Mistake | Consequence |
|---|---|
| Module-level DataLoaders | Cache leaks across requests and users - a security incident |
| A DataLoader batch function returning the wrong order or length | Wrong data silently attached to the wrong object |
| No depth or complexity limits | A four-line query takes the service down |
| Everything marked non-null | One failing leaf blanks the entire response |
| Exposing the database schema as the graph | The API can never outlive the storage design |
Business errors in the top-level errors array | Clients cannot distinguish validation from server faults |
| Auto-generating the schema from an ORM | You have published your tables and called it an API |
| No per-field usage analytics | @deprecated never leads to removal; the schema only grows |
| Adopting federation before you have the org problem | Router complexity with none of the team-autonomy benefit |
| Treating GraphQL as REST with one endpoint | You get the costs and none of the benefits |
Verdict
GraphQL is a good technology that was oversold, and the correction is healthy. The problem it solves - client-determined response shape across many different consumers - is real, and nothing else solves it as cleanly. The bill it presents is also real: caching you rebuild, observability you rework, N+1 vigilance that never ends, and cost limits you own.
The teams doing well with it have many clients, deeply relational data, and enough platform capacity to run the graph properly. The teams regretting it usually adopted it for type safety or fashion, have one web client, and are now maintaining resolver infrastructure to serve responses a REST endpoint would have returned from a CDN.
Count your distinct clients. If the answer is one, you are buying an architecture to solve a problem you do not have.
If you are choosing now: start with REST, and add a GraphQL layer over it when a second and third client with genuinely different needs make the round trips hurt. That order is reversible. The other order - GraphQL first, then discovering the operational bill - is the one people write disillusioned blog posts about.
Sources
- GraphQL specification - the formal releases and current working draft
- GraphQL best practices - pagination, nullability, versioning guidance
- Apollo Federation documentation - subgraphs, keys, composition, the router
- DataLoader - batching and per-request caching, including the ordering contract
- GraphQL Armor - depth, cost and alias limiting plugins
- Shopify GraphQL design tutorial - the best public writing on schema design
- Apollo on the 2026 Gartner Hype Cycle - the vendor's own account of the trough placement



