Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
graphqlapi-designrestbackendarchitecture

GraphQL in 2026: where it still wins and where REST is simpler

Schema design, resolvers, N+1, caching, federation, security and complexity limits - an honest account of what GraphQL costs and when it pays for itself.

T

team

17 min read
A stylised illustration of a graph, with nodes and edges. The nodes are represented as circles, and the edges are represented as lines connecting the circles. The graph is surrounded by a network of gears and pipes, representing the infrastructure that supports it.

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.

graphql
# 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 }
      }
    }
  }
}
This is the whole pitch. On a 200ms mobile link, collapsing three sequential round trips into one is worth more than any amount of server-side optimisation.

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

graphql
# 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!
}
Wrapping money in a type rather than exposing 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.

graphql
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
}
Rule of thumb: non-null for anything the database guarantees, nullable for anything that crosses a service or network boundary. A degraded field beats a blank screen.

Conventions worth adopting on day one

  • Relay connections for every list. edges/node/pageInfo looks 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 errors array 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 = 3 should not become a GraphQL value you can never change.
graphql
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!
}
This is Shopify's pattern and it is the right one. A client can render field-level validation without parsing a top-level 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.

graphql
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.

ts
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),
  },
}
The context object is created per request. Anything request-scoped - the authenticated user, database handles, and crucially the loaders - lives there.

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.

graphql
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
      }
    }
  }
}
One innocent-looking query, several hundred database round trips. Nobody wrote a loop; the execution model created one.

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.

src/loaders.ts
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 ordering requirement is not a style preference. Return a shorter array or a different order and DataLoader hands the wrong object to the wrong resolver - a data-leak-shaped bug that unit tests rarely catch.

The other approaches, and their limits

ApproachHow it worksWhere it breaks down
DataLoaderBatch and dedupe per request tickStill N queries for N distinct entity types; needs discipline everywhere
Look-ahead / projectionInspect the query AST and build one optimised queryComplex to write, and fragile as the schema grows
Query-to-SQL compilersHasura, PostGraphile - compile the whole GraphQL query to one SQL statementTies your API shape closely to your schema; less freedom in resolvers
Aggressive cachingServe from Redis or an in-memory cacheMoves 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.

LayerRESTGraphQL
Browser cacheAutomatic via URL, ETag, Cache-ControlNone - everything is a POST to one URL
CDN cacheWorks out of the boxRequires GET plus persisted queries, or a GraphQL-aware edge
Conditional requestsIf-None-Match304No standard mechanism
Client cacheURL-keyed, simpleNormalised entity cache - powerful, and a real dependency
Cache invalidationPer URLPer 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.

graphql
type Product @cacheControl(maxAge: 300) {
  id: ID!
  name: String!
  price: Money! @cacheControl(maxAge: 30)
  stock: Int! @cacheControl(maxAge: 0, scope: PRIVATE)
}
The composed policy is the most restrictive field in the response. One uncached field makes the whole query uncacheable - which is correct, and means a careless field annotation silently disables caching for everything.

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

graphql
query Bomb {
  orders { customer { orders { customer { orders { customer {
    orders { customer { name }
  } } } } } } }
}
Circular relationships plus unbounded depth. No authentication bypass needed - this is your schema working as designed.

The controls, in the order you should add them

ControlWhat it stops
Depth limitingDeeply nested recursive queries. Cheap to add, blunt but effective
Complexity / cost analysisWide queries that are shallow. Assign a cost per field and reject above a budget
Pagination limitsfirst: 10000. Enforce a maximum in the resolver, not just the docs
Query timeoutsAnything that slipped through the static checks
Persisted queries (allowlist)Everything above, at once - only pre-registered documents run
Introspection controlCasual schema enumeration. Note this is obscurity, not security
Rate limiting by costPer-request limits that ignore how expensive each request is
Batching limitsArray-of-operations requests used to multiply any of the above
ts
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
  }),
})
Set the depth and cost limits by measuring your real client queries, then adding headroom. Guessing produces a limit that either blocks legitimate traffic or blocks nothing.

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.

ts
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
    },
  },
}
Note the per-object check inside a field resolver runs once per row. Authorisation that hits the database is itself an N+1 source - batch it through a loader like everything else.
  • Return null rather 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.

graphql
# 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!
}
The @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 youCosts you
Team autonomy - deploy a subgraph without coordinatingA router to run, scale, monitor and upgrade
One graph for every client, across service boundariesSchema composition as a build step that can fail
Incremental adoption - wrap existing servicesDistributed tracing becomes mandatory, not optional
Governed access to data across systemsCross-subgraph N+1: the router's own fan-out
Type ownership that maps to team ownershipNaming 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.

CostWhat it means in practice
Observability reworkEvery 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 handlingPartial success is normal - data and errors together, status 200. Retry logic, alerting and SLOs all need rewriting around that
N+1 vigilanceEvery new resolver is a potential N+1. This needs to be a review checklist item forever, not a one-time fix
Cost limits and tuningDepth and complexity budgets need measuring, setting and revisiting as the schema grows
Caching infrastructureYou are rebuilding what HTTP gave REST for free
Schema governanceOne global namespace across teams. Without a review process, names collide and conventions drift
Field usage analyticsRequired before you can ever remove a field. This is a platform capability, not a library
Client library weightApollo Client or Relay is a substantial dependency with its own cache semantics to learn
Specialist knowledgeHiring 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 POST to 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.

text
  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 owned
Each protocol where it earns its keep. Internal services stay cacheable and easy to reason about; clients get one query.

It 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

OptionIt wins onConsider it when
REST + OpenAPICaching, simplicity, tooling ubiquity, predictable costMost APIs, most of the time
tRPCEnd-to-end types with no schema language and no codegen stepTypeScript on both ends, one team, no third-party consumers
gRPCThroughput, streaming, strict contractsService-to-service, especially polyglot and high-volume
GraphQLClient-shaped responses across heterogeneous consumersMany different clients against deeply relational data
tRPC is the most common alternative for teams who wanted GraphQL's type safety rather than its query flexibility - which, on inspection, is what a lot of teams actually wanted.

Mistakes that keep recurring

MistakeConsequence
Module-level DataLoadersCache leaks across requests and users - a security incident
A DataLoader batch function returning the wrong order or lengthWrong data silently attached to the wrong object
No depth or complexity limitsA four-line query takes the service down
Everything marked non-nullOne failing leaf blanks the entire response
Exposing the database schema as the graphThe API can never outlive the storage design
Business errors in the top-level errors arrayClients cannot distinguish validation from server faults
Auto-generating the schema from an ORMYou 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 problemRouter complexity with none of the team-autonomy benefit
Treating GraphQL as REST with one endpointYou 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.

Our first question when GraphQL comes up

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

Back to Blog
Share:

Related Posts