GraphQL vs REST: Choosing the Right API Style

A practical comparison of GraphQL and REST — covering over-fetching, the schema type system, the N+1 problem, HTTP caching, and a situation-based recommendation for each.

GraphQL vs REST: Choosing the Right API Style

At some point you have to make a call: REST or GraphQL for the new API? It sounds like an architectural question, but it’s really a trade-off question. Both styles can deliver the data your client needs. What changes is where the complexity lives and who pays the cost when requirements shift.

REST keeps complexity on the client side. Your client makes multiple requests, stitches responses together, and deals with fields it did not need. GraphQL moves complexity to the server. Your server maintains a schema, runs resolvers per field, and figures out how to satisfy exactly what the client asked for.

Neither trade-off is free. The right one depends on what you’re building.

On this page

The problem both API styles are trying to solve

Every API has the same core tension: the server models the world in one shape, and the client needs a different shape for each screen.

A typical user profile page might need a name, avatar URL, and follower count. A typical REST /users/:id endpoint returns the name, email, bio, created date, preferences, last login, role, subscription plan, and a dozen other fields. The client uses three of them and discards the rest. This is over-fetching — the API returned more data than the client needed.

The reverse also happens. A feed screen might need a post title, the author’s name, and the author’s avatar. A REST /posts endpoint returns the posts but not the author details, so the client fires a second request to /users/:id for every post it renders. This is under-fetching — one request gave you enough data to realise you need more data.

Both problems are real, and both grow more expensive as traffic and client diversity increase. GraphQL was originally created at Facebook in 2012 and open-sourced in 2015 specifically because their mobile clients were suffering under this exact pressure.

What REST gets right

REST is not just an API style — it’s a set of constraints that align with how HTTP already works.

Every resource has a URL. GET requests on those URLs are cacheable by default. A CDN or reverse proxy can cache the response to GET /users/42/profile without knowing anything about your application. A Cache-Control: max-age=300 header tells every intermediary — the browser, the CDN, the edge server — that this response is valid for five minutes. The REST API setup covers this in more detail.

Status codes carry semantic meaning. 404 means the resource does not exist. 401 means the caller is unauthenticated. 409 means there is a conflict. Clients, monitoring tools, and load balancers understand these without reading your API documentation first.

curl -s https://api.example.com/users/42
# 404 Not Found — no further parsing needed

The tooling surface is also enormous. Any HTTP client, any language, and any command-line tool works with a REST API. The request format is predictable. The response format is predictable. You can debug a REST endpoint with curl in seconds.

REST makes sense when your data model maps cleanly onto resources, your client data needs roughly match what each endpoint returns, and you want the full weight of HTTP caching working without extra infrastructure. That covers most CRUD applications — admin dashboards, content APIs, mobile backends where each screen has a stable data requirement.

What GraphQL changes

The GraphQL specification defines one of its design principles as “client-specified response”: the client specifies exactly how it will consume the server’s published capabilities, and the response contains exactly what the client asked for — nothing more.

In practice, you start by defining a schema that describes every type in your system and the relationships between them:

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  publishedAt: String!
  author: User!
}

type Query {
  user(id: ID!): User
  posts(limit: Int): [Post!]!
}

A client needing profile data writes a query that requests exactly the three fields it requires:

query GetProfile($userId: ID!) {
  user(id: $userId) {
    name
    email
    posts {
      title
    }
  }
}

The response contains those fields and nothing else. No over-fetching, no second request for nested data.

The schema is also introspectable. You can query it with __schema to discover all available types and fields at runtime, which is how tools like GraphiQL and code generators work — they build from what the schema actually exposes, not from external documentation.

This matters most in two situations. First, when you have multiple clients with different data needs. A mobile app and a web dashboard can query the same API but request different shapes on the same endpoint, without the server needing to build per-client routes. Second, when you need real-time data. The spec defines subscriptions as a first-class operation type alongside queries and mutations. A client opens a persistent connection and receives data pushed from the server whenever the event fires — no polling required.

Before you continue: If you’re using TypeScript with GraphQL, tools like GraphQL Code Generator can derive TypeScript types directly from your schema, making subscription response shapes fully type-checked end-to-end.

The problems GraphQL introduces

The N+1 problem is one of the more reliable ways to introduce a performance regression when you add GraphQL without thinking through the execution model.

Consider a query that fetches a list of posts and each post’s author. A naive implementation runs one database query to get all posts, then runs a separate query for each post’s author. With 50 posts and 50 distinct authors, that’s 51 database round trips for what could be one join. The official GraphQL performance documentation describes this as an inherent risk of the field-level resolver model.

The standard fix is request batching. A tool like DataLoader collects all the author ID lookups that arrive during a single execution tick and issues one WHERE id IN (...) query instead. But that’s something you have to build, configure, and reason about — it doesn’t come for free with any GraphQL server library.

HTTP caching is the other significant cost. The official GraphQL caching documentation points out that in a URL-based API, the URL is a globally unique identifier that any intermediary can cache. In GraphQL, every operation typically goes through a single endpoint as a POST request. A CDN cannot cache a POST body by default. You can work around this with persisted queries — sending a hash of the query string rather than the full body and using GET requests for read-only queries — but that’s infrastructure a REST API doesn’t require.

Error handling also differs subtly. A REST API uses HTTP status codes to signal failure. GraphQL often returns 200 OK even when a field resolver fails — the error appears in an errors array in the response body alongside partial data. Monitoring tools that alert on non-200 responses will miss GraphQL field errors without additional setup.

Check this before picking GraphQL

  • You have a plan for the N+1 problem — either DataLoader, query batching, or a data layer that handles it automatically
  • Your deployment supports persistent connections for subscriptions, or you have an alternative real-time strategy
  • Your team can maintain a typed schema as the product evolves
  • HTTP caching is either not critical, or you have a persisted query strategy

When the differences actually matter

Concern REST GraphQL
Data shape Fixed per endpoint Defined per query
HTTP caching Works natively via URL and Cache-Control Requires workarounds for POST requests
Over-fetching Common without sparse-field support Eliminated — client requests what it needs
Under-fetching Common on complex screens Eliminated — single query for nested data
N+1 queries Easy to avoid with a well-designed query layer Possible without DataLoader or batching
Real-time Requires WebSocket or SSE setup separately Subscriptions built into the spec
Schema contract Implicit — documented separately Enforced by the type system, introspectable
Tooling maturity Extremely broad Strong in major ecosystems, growing fast
Error signalling HTTP status codes Errors in response body, status often 200

The row that surprises people most is HTTP caching. REST’s edge-caching story is mature and largely free — your existing CDN handles it. GraphQL’s caching story requires deliberate architecture. If you run a high-traffic read-heavy API and CDN caching is part of how you handle load, that difference is significant.

Pick based on your situation

No universal winner here. The honest recommendation is situational.

Your situation The better fit Reason
CRUD app with stable, bounded data per screen REST Simpler setup, better HTTP caching, less schema overhead
Multiple clients (mobile, web, third-party) needing different shapes GraphQL Eliminates over-fetching and per-client endpoint proliferation
API where CDN caching is a primary performance strategy REST URL-based caching works without extra infrastructure
Complex hierarchical queries — nested data across relationships GraphQL Single query replaces multiple round trips
Small team moving fast, minimal API consumers REST Lower ceremony, faster to iterate
Real-time features that need server push, not polling GraphQL Subscriptions are first-class in the spec
API consumed by curl, scripts, or third-party HTTP clients REST Standard HTTP semantics, no schema knowledge required
Frontend team writing per-component queries with generated types GraphQL Schema-derived TypeScript types reduce integration bugs

A Prisma-backed data layer pairs well with either approach — Prisma’s query API works regardless of your transport layer. Next.js route handlers also support both cleanly in the same project.

The decision usually comes down to one question: are your clients diverse and unpredictable in their data needs, or are they stable and bounded? Diverse, unpredictable clients pay back GraphQL’s complexity cost. Stable, bounded clients rarely do.

Sources