BytePane

REST vs GraphQL: Which API Approach Should You Use?

API21 min read

Your startup is building a mobile app with iOS, Android, and web clients. Each client needs a different subset of the same user data. The mobile app needs name, avatar, and notification count. The web dashboard needs name, email, roles, activity history, and billing status. With REST, you either over-fetch on mobile (wasting bandwidth) or build three separate endpoints. You are six weeks into the project when someone suggests GraphQL.

This is exactly the scenario GraphQL was designed for. But it is also a scenario where plenty of teams pick GraphQL, discover the N+1 problem, fight HTTP caching limitations, and spend two sprints on tooling before shipping the first feature.

This guide is not a sales pitch for either approach. It is a trade-off analysis with real data. According to the Postman 2025 State of the API Report, 93% of development teams use REST and 33% use GraphQL — numbers that reflect a world where both coexist rather than compete.

Key Takeaways

  • REST dominates public APIs. 93% of developers use REST (Postman 2025). It has native HTTP caching, simplicity, and universal tool support.
  • GraphQL solves over-fetching and multi-round-trip problems. It grew 340% among Fortune 500 companies but requires DataLoader, query complexity limits, and specialized caching.
  • Caching is the biggest practical difference. REST GET responses cache at the CDN level for free. All GraphQL goes through one POST endpoint — standard HTTP caches cannot distinguish queries.
  • N+1 is GraphQL's biggest operational hazard. A single query fetching 100 posts with authors fires 101 DB queries without DataLoader.
  • The mature answer is both. GraphQL for internal client-server contracts, REST for public APIs, file uploads, and webhooks.

How REST Works: The Architecture That Won the Web

REST (Representational State Transfer) was defined by Roy Fielding in his 2000 doctoral dissertation at UC Irvine. It is an architectural style, not a protocol, built on six constraints: statelessness, client-server separation, cacheability, uniform interface, layered system, and code on demand (optional). In practice, "REST API" in 2026 means HTTP + JSON with resource-oriented URL design.

# REST: Resources as nouns, HTTP methods as verbs

GET    /users           → List all users
POST   /users           → Create a user
GET    /users/42        → Get user 42
PUT    /users/42        → Replace user 42
PATCH  /users/42        → Partially update user 42
DELETE /users/42        → Delete user 42

# Related resources — nested or linked
GET    /users/42/posts  → Posts by user 42
GET    /posts/7/comments → Comments on post 7

# Each endpoint returns a fixed shape:
{
  "id": 42,
  "name": "Alice",
  "email": "[email protected]",
  "role": "admin",
  "createdAt": "2024-03-15T10:00:00Z",
  "notificationCount": 3,
  "billingStatus": "active"       ← mobile doesn't need this
}

The fixed response shape is both REST's strength (predictability, cacheability) and its weakness (over-fetching). The mobile client requesting GET /users/42 receives the full user object regardless of which fields it needs.

How GraphQL Works: Queries, Mutations, Subscriptions

GraphQL was developed internally at Facebook starting in 2012 and open-sourced in 2015. It is a query language for APIs — clients specify exactly what data they need in each request. Every GraphQL API exposes a single endpoint (POST /graphql) and a type system that describes all available data.

# Schema (server-side type definition)
type User {
  id: ID!
  name: String!
  email: String!
  role: String!
  notificationCount: Int!
  billingStatus: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
  comments: [Comment!]!
}

# Mobile client query — fetches ONLY what it needs
query {
  user(id: "42") {
    name
    notificationCount
  }
}
# Response: { "user": { "name": "Alice", "notificationCount": 3 } }

# Web dashboard query — fetches its different set of fields
query {
  user(id: "42") {
    name
    email
    role
    billingStatus
    posts {
      title
    }
  }
}

# Mutation (write operation)
mutation {
  updateUser(id: "42", input: { role: "viewer" }) {
    id
    role
  }
}

# Subscription (real-time, over WebSocket)
subscription {
  newComment(postId: "7") {
    body
    author { name }
  }
}

The type system is the key. Every field in a GraphQL response is declared in the schema, which gives you auto-generated documentation, type-safe client code generation (via GraphQL Code Generator), and introspection — the ability to query the API for its own schema.

Side-by-Side Comparison

DimensionRESTGraphQL
Endpoint shapeMultiple endpoints per resourceSingle endpoint (/graphql)
Data fetchingFixed response shape (over/under-fetch)Client specifies exact fields needed
HTTP cachingNative — GET requests cache at CDN/browser levelRequires persisted queries or Apollo CDN caching
Type systemOptional (OpenAPI/JSON Schema)Built-in, introspectable SDL
Multiple resourcesMultiple round trips (or custom aggregator)Single query, nested in the schema graph
VersioningURL-based (/v1/, /v2/)Schema evolution with @deprecated
File uploadsNative multipart/form-dataMultipart spec workaround required
Real-timePolling or SSEBuilt-in subscriptions over WebSocket
DoS riskLow — fixed response shapesDeeply nested queries can hammer the DB
Learning curveLow — HTTP is universalHigher — SDL, resolvers, DataLoader, tooling
ToolingUniversal (curl, Postman, browser)Specialized (Apollo Studio, GraphiQL)

The N+1 Problem: GraphQL's Most Dangerous Default

The N+1 problem is the most common performance trap in GraphQL. It is not a GraphQL-specific bug — it is an architectural consequence of how resolvers execute. Every beginner GraphQL API in production has this issue within the first week of real queries.

# Client query
query {
  posts(limit: 100) {
    title
    author {
      name
    }
  }
}

# Naive resolver implementation
const resolvers = {
  Query: {
    posts: () => db.query('SELECT * FROM posts LIMIT 100'),
    // ↑ 1 query — returns 100 posts
  },
  Post: {
    author: (post) => db.query(
      'SELECT * FROM users WHERE id = $1', [post.authorId]
    ),
    // ↑ Fires for EACH of 100 posts = 100 additional queries
    // Total: 101 queries for a single GraphQL request
  },
}

// ─────────────────────────────────────────────────────────
// FIX: DataLoader batches and deduplicates all author loads
// into a SINGLE query at the end of the event loop tick

import DataLoader from 'dataloader'

const userLoader = new DataLoader(async (userIds) => {
  // Called ONCE with all collected IDs: [1, 2, 3, ...]
  const users = await db.query(
    'SELECT * FROM users WHERE id = ANY($1)',
    [userIds]
  )
  // Must return in same order as input IDs
  return userIds.map(id => users.find(u => u.id === id))
})

const resolvers = {
  Post: {
    author: (post) => userLoader.load(post.authorId),
    // 100 calls → DataLoader batches into 1 DB query
  },
}

DataLoader was open-sourced by Facebook in 2015 alongside GraphQL. It is not optional — it is a prerequisite for any GraphQL server that handles relational data. Without it, a simple query on a social feed can generate thousands of database queries.

HTTP Caching: REST's Decisive Structural Advantage

REST's GET responses cache automatically at every layer — browsers, CDNs (Cloudflare, Fastly, CloudFront), and reverse proxies (Nginx, Varnish). This is not a configuration advantage; it is a consequence of HTTP semantics. GET /products/featured with a 60-second Cache-Control means your origin server handles one request per minute regardless of traffic.

# REST: CDN-cacheable by default
GET /products/featured
Cache-Control: public, max-age=60, stale-while-revalidate=30
ETag: "abc123"

# 1,000,000 users per hour → CDN serves them all
# Origin handles ~17 requests/minute (one per 60-second window)

# ─────────────────────────────────────────────────────────

# GraphQL: all queries POST /graphql — CDN cannot cache
POST /graphql
{ "query": "{ products(featured: true) { name price } }" }
# Every request hits origin — no CDN benefit

# Solution 1: Persisted queries (hash-based GET requests)
# Client registers query at build time, sends hash at runtime
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc..."}}
# Now it's a cacheable GET — CDN can cache by URL+hash

# Apollo Server persisted query setup
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries'
import { sha256 } from 'crypto-hash'

const link = createPersistedQueryLink({ sha256 }).concat(httpLink)

# Solution 2: @cacheControl directive (server-driven cache hints)
type Product @cacheControl(maxAge: 60) {
  id: ID!
  name: String! @cacheControl(maxAge: 300)
  price: Float!
  stock: Int! @cacheControl(maxAge: 0)  # Never cache live inventory
}

For content-heavy applications where most responses are read-only (e-commerce catalog, news feeds, documentation), REST's caching advantage translates directly into lower infrastructure cost and lower latency. GraphQL's caching workarounds add significant complexity.

GraphQL Security: Protecting Against Query Abuse

GraphQL's flexibility is a security surface. An attacker can craft a deeply nested query that triggers thousands of resolver calls. This is not hypothetical — HackerOne has documented multiple GraphQL DoS vulnerabilities in production APIs. Three defenses are mandatory in any public-facing GraphQL server.

# Dangerous query — exponential resolver calls
query EvilQuery {
  user(id: "1") {
    friends {
      friends {
        friends {
          friends {
            name  # 10,000+ resolver invocations
          }
        }
      }
    }
  }
}

# Defense 1: Query depth limiting
import depthLimit from 'graphql-depth-limit'

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(5)],  // Reject queries > 5 levels deep
})

# Defense 2: Query complexity analysis
import { createComplexityLimitRule } from 'graphql-query-complexity'

const server = new ApolloServer({
  validationRules: [
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
    }),
  ],
})

# Defense 3: Disable introspection in production
const server = new ApolloServer({
  schema,
  introspection: process.env.NODE_ENV !== 'production',
  // Introspection in prod leaks your full schema to attackers
})

Adoption Reality: Who Uses What in Production

The numbers provide an honest picture of where each technology actually lands in 2026:

  • 93% of developers use REST in some capacity, per the Postman 2025 State of the API Report (5,700+ respondents). REST is not declining — it is the baseline.
  • 33% use GraphQL alongside REST, reflecting real coexistence rather than replacement. Most GraphQL adopters keep REST for existing public APIs.
  • 340% Fortune 500 GraphQL growth per Postman's enterprise data — driven by GitHub (2016), Shopify (2018), Twitter/X (2020), and Netflix (2021) adopting GraphQL for internal APIs.
  • Gartner projects 60% enterprise GraphQL usage by 2027, up from less than 30% in 2024 — but this measures adoption of any GraphQL, including narrow internal use.
  • 156% growth in GraphQL job postings on major job platforms per Postman 2025, indicating continued investment in the ecosystem.

The practical conclusion: GraphQL has real, growing production adoption in large organizations. For public APIs where third-party developers consume your API with varied tools, REST remains the pragmatic choice.

Real-World Code: Both Patterns Side by Side

# ── REST Implementation (Express + TypeScript) ──────────────

import express from 'express'
const router = express.Router()

// GET /api/v1/users/:id — returns full user object
router.get('/users/:id', async (req, res) => {
  const user = await db.users.findUnique({
    where: { id: req.params.id },
    include: { posts: true },  // Always included — over-fetch risk
  })
  if (!user) return res.status(404).json({ error: 'User not found' })
  res.json(user)
})

// Caching with ETag
router.get('/products/featured', async (req, res) => {
  const products = await getFromCache('featured-products', () =>
    db.products.findMany({ where: { featured: true } })
  )
  const etag = crypto.createHash('md5').update(JSON.stringify(products)).digest('hex')
  if (req.headers['if-none-match'] === etag) return res.status(304).end()
  res.set({ 'Cache-Control': 'public, max-age=60', ETag: etag }).json(products)
})

# ── GraphQL Implementation (Apollo Server + TypeScript) ──────

import { ApolloServer, gql } from '@apollo/server'
import DataLoader from 'dataloader'

const typeDefs = gql`
  type Query {
    user(id: ID!): User
  }
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }
  type Post {
    id: ID!
    title: String!
    author: User!
  }
`

// Context factory — create one DataLoader per request
function createContext() {
  return {
    userLoader: new DataLoader(async (ids: readonly string[]) => {
      const users = await db.users.findMany({
        where: { id: { in: ids as string[] } },
      })
      return ids.map(id => users.find(u => u.id === id) ?? null)
    }),
  }
}

const resolvers = {
  Query: {
    user: (_, { id }, context) => context.userLoader.load(id),
  },
  Post: {
    author: (post, _, context) => context.userLoader.load(post.authorId),
    // 100 posts → 1 batched DB query, not 100
  },
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
})

Decision Framework: When to Choose Each

Use this as a starting point, not a mandate:

# Choose REST when:
✓ Building a public API consumed by third-party developers
✓ Caching is critical (content APIs, product catalogs, public data)
✓ Simple CRUD with predictable, stable data shapes
✓ Team is unfamiliar with GraphQL tooling
✓ You need file upload support without workarounds
✓ You need webhook support (GraphQL has no webhook story)
✓ Your API is consumed by mobile apps where REST SDKs exist

# Choose GraphQL when:
✓ Multiple clients (web, iOS, Android, IoT) need different data shapes
✓ Your data is highly relational (social graphs, e-commerce with variants)
✓ You want client-driven queries without building custom aggregation endpoints
✓ You value schema-as-contract for frontend/backend coordination
✓ You need built-in real-time via subscriptions
✓ Team has experience with Apollo/urql/DataLoader ecosystem

# Use both when:
✓ Existing REST public API + new GraphQL internal API for frontend
✓ GraphQL as a BFF (Backend for Frontend) layer over existing REST microservices
✓ REST for operations (webhooks, file upload, health checks) + GraphQL for data
✓ Public REST API + GraphQL for first-party mobile/web clients

For a deeper look at REST API design principles, see the REST API Best Practices guide. For understanding the HTTP fundamentals that underpin both approaches, the HTTP Status Codes guide covers the response semantics both styles share.

Schema Evolution: GraphQL's Versioning Advantage

Facebook (now Meta) has run a single GraphQL endpoint without versioning since 2012. The mechanism is schema evolution: add new fields freely (backwards compatible), mark old fields @deprecated, then remove them only after confirming zero client usage via schema analytics.

# GraphQL schema evolution — no versioning needed

# Step 1: Add new field (backwards compatible — clients not requesting it ignore it)
type User {
  id: ID!
  name: String!
  email: String!                         # existing
  displayName: String                    # new — optional, clients can ignore
}

# Step 2: Deprecate old field with reason
type User {
  id: ID!
  name: String! @deprecated(reason: "Use displayName instead, removing 2027-01-01")
  displayName: String!
  email: String!
}

# Step 3: Track usage with Apollo Studio / GraphQL Hive
# Check: is anyone still querying the 'name' field?
# If field usage = 0 for 90 days → safe to remove

# Compare with REST versioning:
# v1: GET /api/v1/users/42  → { name: "Alice" }
# v2: GET /api/v2/users/42  → { displayName: "Alice" }
# Now running /v1 and /v2 forever, maintaining two codepaths

gRPC: The Third Option Worth Knowing

When developers compare REST and GraphQL they often miss gRPC, which solves a different problem: high-throughput, type-safe service-to-service communication. gRPC uses Protocol Buffers (binary encoding, ~3-10× smaller payloads than JSON), HTTP/2 multiplexing, and generated client/server stubs from a schema file.

gRPC is not a replacement for REST or GraphQL in browser-facing APIs — browser support requires gRPC-Web, which adds complexity. But for internal microservices where performance and type safety matter, gRPC is worth considering alongside both. Google, Netflix, and Cloudflare use it extensively for internal traffic. The OpenAPI guide covers REST API documentation and contract tooling that complements either approach.

Frequently Asked Questions

Is GraphQL better than REST?

Neither is universally better. REST is simpler, has native HTTP caching, and powers 93% of public APIs per Postman 2025. GraphQL wins for complex, relationship-heavy data with diverse clients. The right choice depends on your data shape, client variety, and team familiarity. Many teams use both.

Does GraphQL replace REST?

No. Despite 340% adoption growth among Fortune 500 companies per the Postman State of the API Report, GraphQL still powers a small fraction of public APIs. REST remains dominant for public-facing, cacheable endpoints. The trend is coexistence: GraphQL for internal contracts, REST for external APIs and webhooks.

What is the N+1 problem in GraphQL?

N+1 occurs when a query fetches N parent items and each triggers an additional DB call for a related field. 100 posts with authors = 101 queries. The fix is DataLoader, which batches all author IDs into one query per resolver type. Every production GraphQL server must implement DataLoader — no exceptions.

Why does GraphQL not cache well?

All GraphQL queries hit a single POST endpoint. HTTP caches cannot differentiate requests by body. Solutions include persisted queries (hash-based GET requests), Apollo CDN cache hints via @cacheControl, or a CDN-aware gateway. These work but add complexity compared to REST's zero-config caching.

When should I use REST instead of GraphQL?

Use REST for public APIs consumed by third parties, when caching is critical, for simple CRUD with stable data shapes, when your team lacks GraphQL experience, or when you need native file uploads. REST is also simpler for microservices communication with stable contracts.

Can I use GraphQL and REST in the same project?

Yes — the most common production pattern. REST for public APIs, webhooks, and file uploads. GraphQL for the internal API between frontend and backend. Apollo RESTDataSource lets GraphQL resolvers wrap existing REST services, giving frontend GraphQL ergonomics without rewriting backends.

How is API versioning different in GraphQL vs REST?

REST versions via URL paths (/v1/, /v2/) running in parallel. GraphQL avoids versioning through schema evolution: add fields freely, mark old fields @deprecated, remove after confirming zero client usage via analytics. Meta has run a single GraphQL endpoint without versioning since 2012.

Test and Debug Your APIs

Whether you are building REST or GraphQL, use BytePane's JSON Formatter to inspect and pretty-print API responses. For HTTP fundamentals both styles share, see the HTTP Status Codes reference. For securing your API endpoints, the API Authentication Methods guide covers JWT, OAuth, and API key strategies for both approaches.

Open JSON Formatter

Related Articles