VS GraphQL REST
API

GraphQL vs REST: Making the Right Choice

Mayur Dabhi
Mayur Dabhi
March 16, 2026
26 min read

You build a profile screen and it needs the user, their posts, and the comments on each post. With a typical REST API that's one request for the user, one for the posts, then one more per post for comments, and every response carries fields the screen never shows. Then the mobile team asks for a lighter version of the same screen, and you're adding ?fields= params or a new endpoint. That's usually the moment someone on the team says "should we just use GraphQL?"

This post compares GraphQL and REST on the things that actually matter when you pick one: how data gets fetched, schemas, performance and the N+1 problem, security, caching, and how to migrate if you change your mind later.

What's covered
  • Core architectural differences between GraphQL and REST
  • How data fetching works in each paradigm
  • Performance considerations and optimization strategies
  • Real-world use cases and when to choose each
  • Implementation examples and migration strategies
  • Practical advice for running either one in production

Understanding the Fundamentals

The two solve different problems, so it helps to be clear about what each one was designed for before comparing them.

REST: Representational State Transfer

Roy Fielding described REST in his 2000 doctoral dissertation as a set of constraints for building scalable web services. In practice, a RESTful API exposes resources at URLs and you act on them with standard HTTP methods.

Resource-Based

Everything is a resource accessed via unique URLs. Each endpoint represents a specific entity or collection.

HTTP Methods

Uses GET, POST, PUT, PATCH, DELETE to perform CRUD operations on resources.

Stateless

Each request contains all information needed. Server doesn't store client context between requests.

Cacheable

Responses can be cached using standard HTTP caching mechanisms for better performance.

GraphQL: A Query Language for APIs

Facebook built GraphQL internally in 2012 and open-sourced it in 2015. It's a query language plus a runtime that executes those queries against your schema. The client writes down the fields it wants and gets back that shape, nothing more.

Single Endpoint

All queries go to one endpoint. The query itself determines what data is returned.

Precise Data Fetching

Clients specify exactly what fields they need, eliminating over-fetching and under-fetching.

Strongly Typed

Schema defines types and relationships. Provides validation and introspection capabilities.

Real-time Subscriptions

Subscriptions are part of the spec, though you still need a transport (usually WebSockets) to run them.

Data Fetching: The Core Difference

Most of the practical difference shows up in how a client gets its data. Take the profile screen from the intro: a user, their posts, and the comments on those posts.

REST API: Multiple Requests Client GET /users/1 GET /users/1/posts GET /posts/1/comments GET /posts/2/comments 4 Round Trips + Over-fetching GraphQL: Single Request Client POST /graphql query { user(id: 1) { name, posts { title, comments } } 1 Round Trip + Exact Data Impact on Performance REST: Higher Latency Multiple network round trips Extra data transferred GraphQL: Lower Latency Single network request Only requested data returned

Comparison of data fetching patterns: REST requires multiple requests while GraphQL fetches all data in one query

REST Approach: Multiple Endpoints

With a typical REST API you hit several endpoints and stitch the results together on the client:

GET /api/users/1
GET /api/users/1/posts
GET /api/posts/1/comments
GET /api/posts/2/comments
JavaScript: REST Data Fetching
// Fetching user data with REST: multiple requests required
async function getUserWithPosts(userId) {
  // First request: Get user
  const userResponse = await fetch(`/api/users/${userId}`);
  const user = await userResponse.json();
  
  // Second request: Get user's posts
  const postsResponse = await fetch(`/api/users/${userId}/posts`);
  const posts = await postsResponse.json();
  
  // Third+ requests: Get comments for each post
  const postsWithComments = await Promise.all(
    posts.map(async (post) => {
      const commentsResponse = await fetch(`/api/posts/${post.id}/comments`);
      const comments = await commentsResponse.json();
      return { ...post, comments };
    })
  );
  
  return {
    ...user,
    posts: postsWithComments
  };
}

// Usage
const userData = await getUserWithPosts(1);
// Problem: Over-fetching, we get ALL fields from each endpoint
// Problem: Under-fetching, multiple round trips needed

GraphQL Approach: Single Query

With GraphQL the client describes the whole tree in one query and sends it in one request:

GraphQL Query
query GetUserWithPosts($userId: ID!) {
  user(id: $userId) {
    id
    name
    email
    avatar
    posts {
      id
      title
      createdAt
      comments {
        id
        body
        author {
          name
        }
      }
    }
  }
}
JavaScript: GraphQL Data Fetching
// Fetching user data with GraphQL: single request
async function getUserWithPosts(userId) {
  const query = `
    query GetUserWithPosts($userId: ID!) {
      user(id: $userId) {
        id
        name
        email
        avatar
        posts {
          id
          title
          createdAt
          comments {
            id
            body
            author { name }
          }
        }
      }
    }
  `;
  
  const response = await fetch('/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query,
      variables: { userId }
    })
  });
  
  const { data } = await response.json();
  return data.user;
}

// Usage: exact data, single request
const userData = await getUserWithPosts("1");
The real trade-off

REST is resource-oriented: the server decides which endpoints exist and what each returns. GraphQL is demand-oriented: the client decides what it needs. Moving that decision from server to client is where both the flexibility and most of the new problems come from.

Detailed Comparison

Aspect REST GraphQL
Endpoints Multiple endpoints for different resources Single endpoint for all queries
Data Fetching Fixed data structure per endpoint Client specifies exact data needs
Over/Under-fetching Common problem requiring workarounds Largely avoided, since the client picks fields
Versioning URL or header-based versioning Schema evolution, deprecation
Caching HTTP caching works out of the box Requires custom implementation
Error Handling HTTP status codes Usually 200, errors in an errors array
Type System Optional (OpenAPI/Swagger) Built-in, required schema
Real-time Requires WebSockets/SSE separately Built-in subscriptions
Learning Curve Lower, familiar patterns Higher, new concepts to learn
Tooling Mature, widespread Good and improving (codegen, GraphiQL, Apollo)
The 200 OK gotcha

Because most GraphQL servers answer 200 OK even when a resolver fails, uptime checks and dashboards that only count 4xx/5xx responses will happily report zero errors while users see broken screens. A response can also be partial: data has some fields filled, others null, and an errors array explains why. Log and alert on the errors array, and on the client don't assume data is complete just because the request succeeded.

Schema and Type System

Every GraphQL API starts from a schema that lists every type and field a client can ask for. REST can get something similar through OpenAPI, but it's optional and often drifts out of date. Here's the same blog model both ways:

# GraphQL Schema Definition Language (SDL)
type User {
  id: ID!
  name: String!
  email: String!
  avatar: String
  posts: [Post!]!
  followers: [User!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  comments: [Comment!]!
  likes: Int!
  published: Boolean!
  createdAt: DateTime!
}

type Comment {
  id: ID!
  body: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
  posts(authorId: ID, published: Boolean): [Post!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

input CreateUserInput {
  name: String!
  email: String!
  password: String!
}

input CreatePostInput {
  title: String!
  body: String!
  published: Boolean = false
}
# OpenAPI 3.0 Specification (YAML)
openapi: 3.0.0
info:
  title: Blog API
  version: 1.0.0
paths:
  /users/{id}:
    get:
      summary: Get user by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: User object
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
  /posts:
    get:
      summary: List posts
      parameters:
        - name: authorId
          in: query
          schema:
            type: string
        - name: published
          in: query
          schema:
            type: boolean
      responses:
        '200':
          description: Array of posts
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Post'
components:
  schemas:
    User:
      type: object
      required: [id, name, email]
      properties:
        id:
          type: string
        name:
          type: string
        email:
          type: string
        avatar:
          type: string
          nullable: true
    Post:
      type: object
      required: [id, title, body, authorId]
      properties:
        id:
          type: string
        title:
          type: string
        body:
          type: string
        authorId:
          type: string
        published:
          type: boolean
          default: false
What the schema gets you

Having the schema as the contract pays off in a few concrete ways:

  • Introspection: Clients can query the schema itself to discover available types and fields
  • Validation: Invalid queries are rejected before execution
  • Documentation: The schema is the docs, so it can't fall out of sync with the code
  • Tooling: IDE autocomplete, type generation, and more

Performance Considerations

People often say GraphQL is "faster." It can be on the network side, especially on mobile, but it moves cost onto the server and makes caching harder. Neither one is faster in general:

Performance Characteristics Network Performance Round Trips REST: More Data Transfer GraphQL: Less Latency (Mobile) GraphQL: Better Server Performance Caching Ease REST: Easier Query Complexity GraphQL: Higher Predictability REST: Better Optimization Strategies REST Optimizations • HTTP caching (ETag, Cache-Control) • CDN caching • Pagination GraphQL Optimizations • Persisted queries • DataLoader • Query complexity limits Both Benefit From • Database optimization • Edge caching • Compression

Performance trade-offs between REST and GraphQL across different dimensions

The N+1 Problem

This is the first performance problem most GraphQL servers hit. Resolvers run per field, per object, so a list query with a nested relation quietly turns into one database query per row:

The N+1 Problem Example
// GraphQL Query
query {
  posts {      # 1 query to fetch posts
    title
    author {   # N queries, one per post to fetch author!
      name
    }
  }
}

// Without optimization, this generates:
// SELECT * FROM posts;                    -- 1 query
// SELECT * FROM users WHERE id = 1;       -- N queries
// SELECT * FROM users WHERE id = 2;       -- (one per post)
// SELECT * FROM users WHERE id = 3;
// ... and so on

The standard fix in Node is DataLoader, which collects all the load() calls made in one tick and turns them into a single batched query. One thing to get right: create the loaders per request (in the context function), not once at module level. A shared loader caches results across requests, so one user can end up seeing data loaded for another, and permission checks get skipped on cache hits.

JavaScript: DataLoader Solution
import DataLoader from 'dataloader';

// Create a batch loading function
const userLoader = new DataLoader(async (userIds) => {
  // Single query to fetch all users at once
  const users = await User.findAll({
    where: { id: userIds }
  });
  
  // Return users in the same order as requested IDs
  const userMap = users.reduce((map, user) => {
    map[user.id] = user;
    return map;
  }, {});
  
  return userIds.map(id => userMap[id]);
});

// In your resolver
const resolvers = {
  Post: {
    author: (post, args, context) => {
      // Uses batching: all author requests in a single query
      return context.loaders.userLoader.load(post.authorId);
    }
  }
};

// Now the same query generates:
// SELECT * FROM posts;                              -- 1 query
// SELECT * FROM users WHERE id IN (1, 2, 3, ...);  -- 1 query (batched!)
Performance Warning

The same flexibility that helps your frontend also lets anyone write a deeply nested query (posts → author → posts → author...) that takes your database down. If the endpoint is reachable from the internet, put these in place:

  • Query depth limiting
  • Query complexity analysis
  • Rate limiting per client
  • Timeout mechanisms

Security Considerations

REST security mostly happens per route. With GraphQL there's one route, so the checks move down to fields and to the shape of the query itself:

REST Security

  • Standard HTTP authentication
  • URL-based authorization
  • Rate limiting per endpoint
  • Input validation on each route
  • CORS configuration

GraphQL Security

  • Field-level authorization
  • Query complexity limits
  • Depth limiting
  • Introspection disabling (production)
  • Persisted queries only
GraphQL Security Implementation
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    // Limit query depth to prevent deeply nested attacks
    depthLimit(10),
    
    // Limit query complexity based on field weights
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 10,
      listFactor: 20,
    }),
  ],
  
  // Disable introspection in production
  introspection: process.env.NODE_ENV !== 'production',
  
  // Field-level authorization in context
  context: ({ req }) => ({
    user: authenticateUser(req),
    permissions: getUserPermissions(req),
  }),
});

// Field-level authorization in resolvers
const resolvers = {
  User: {
    email: (user, args, context) => {
      // Only return email if user owns the profile or is admin
      if (context.user.id === user.id || context.permissions.isAdmin) {
        return user.email;
      }
      return null;
    },
    
    // Sensitive field with role check
    salary: (user, args, context) => {
      if (!context.permissions.canViewSalary) {
        throw new ForbiddenError('Not authorized to view salary');
      }
      return user.salary;
    }
  }
};

When to Use Each

Neither is "better" in general. The question is which set of problems you'd rather have. This is roughly how I'd decide:

Choose GraphQL When:

  • Multiple clients with different data needs: a mobile app needs far less than a web dashboard
  • Complex, interconnected data: social graphs, e-commerce catalogs with lots of relations
  • Rapid frontend iteration: frontend teams can use existing fields in new ways without waiting on a backend change
  • Real-time features: subscriptions are part of the spec
  • Mobile applications: fewer round trips and smaller payloads matter on slow networks
  • Microservices aggregation: one GraphQL layer can put several services behind a single API

Choose REST When:

  • Simple CRUD operations: basic resource management doesn't need a schema and resolver layer
  • Caching matters a lot: HTTP caching, CDNs and browser caches just work with GET URLs
  • File uploads/downloads: binary data fits HTTP naturally; GraphQL uploads need a non-standard multipart spec
  • Team familiarity: almost every developer already knows REST conventions
  • Public APIs: consumers can hit it with curl and nothing else
  • Small team or tight budget: less infrastructure and less to secure

Quick Decision Guide

Q: Do you have multiple clients (web, mobile, TV) with different data needs?
Yes → GraphQL, so each client requests exactly what it needs
No, single client → Consider REST for simplicity
Q: Do you depend on HTTP or CDN caching to hit your performance targets?
Yes → REST, which gets HTTP caching for free
No, or willing to implement custom caching → GraphQL works
Q: Do you need real-time updates (live notifications, chat)?
Yes → GraphQL Subscriptions fit well (though plain WebSockets or SSE next to REST work too)
No → Both work equally well

Real-World Implementation Examples

Two well-known public APIs went in opposite directions, and both had good reasons.

GitHub: Adding GraphQL

GitHub launched a GraphQL API (v4) alongside its REST API (v3), and still runs both. The reasons they gave publicly come down to:

  • Fewer round trips: integrations needed several REST calls to assemble one view of a repository
  • Less over-fetching: REST responses carried far more data than most clients used
  • Better tooling: introspection and a typed schema for API consumers
GitHub GraphQL API Example
query {
  repository(owner: "facebook", name: "react") {
    name
    description
    stargazerCount
    forkCount
    issues(first: 5, states: OPEN) {
      nodes {
        title
        createdAt
        author { login }
      }
    }
    pullRequests(first: 5, states: OPEN) {
      nodes {
        title
        additions
        deletions
      }
    }
  }
}

Stripe: Staying with REST

Stripe's REST API is often held up as an example of good API design. Why REST suits it:

  • Predictable resource operations: payments map cleanly onto create, retrieve, update
  • Simplicity: you can make a test charge with one curl command
  • Idempotency: an Idempotency-Key header on POST makes retries safe, which matters a lot when money moves
  • Expansion when needed: the expand[] parameter covers many of the cases where you'd otherwise want GraphQL
Stripe REST API Example
// Create a payment intent
curl https://api.stripe.com/v1/payment_intents \
  -u sk_test_xxx: \
  -d amount=2000 \
  -d currency=usd \
  -d "payment_method_types[]"=card

// Response
{
  "id": "pi_1abc123",
  "object": "payment_intent",
  "amount": 2000,
  "currency": "usd",
  "status": "requires_payment_method",
  "client_secret": "pi_1abc123_secret_xyz"
}

Hybrid Approaches

You don't have to pick one for everything. A common setup puts GraphQL in front of the services that feed complex screens and keeps plain REST for payments, webhooks and file handling:

Hybrid Architecture Pattern Client Applications API Gateway / BFF (Backend for Frontend) GraphQL Gateway Complex queries, Aggregation REST APIs Simple CRUD, File uploads User Service Post Service Payment API File Storage

Hybrid architecture: GraphQL for complex data aggregation, REST for simple operations and file handling

Best Practice: Start Simple

If you're unsure, start with REST. It's simpler to build and easier to debug, and you can put a GraphQL layer on top of existing REST services later if the need shows up. Going the other way, ripping GraphQL out once clients depend on it, is much more painful.

Migration Strategies

If you do decide to move from REST to GraphQL, do it incrementally:

1

Start with a GraphQL Wrapper

Create a GraphQL schema that wraps your existing REST endpoints. This allows gradual migration without rewriting backend services.

2

Migrate High-Value Endpoints First

Identify endpoints that cause the most over-fetching or require multiple calls. These benefit most from GraphQL.

3

Run Both in Parallel

Maintain both APIs during migration. Deprecate REST endpoints gradually as clients migrate to GraphQL.

4

Monitor and Optimize

Track query patterns, performance metrics, and error rates. Optimize resolvers based on actual usage.

When I'd Pick GraphQL (and When I Wouldn't)

Both run in production at very large scale, so "will it scale" isn't really the question. What each one makes easy is different:

GraphQL Wins At

  • Flexible data fetching for diverse clients
  • Reducing network round trips
  • Strong typing and introspection
  • Real-time subscriptions
  • Frontend development velocity

REST Wins At

  • Simplicity and familiarity
  • HTTP caching out of the box
  • File uploads and downloads
  • Predictable performance
  • Lower learning curve

Where I'd skip GraphQL

  • One backend, one frontend, same team. If the people writing the API also write the only client, they can shape REST responses to fit the screens. A schema, resolvers and DataLoaders are overhead you don't need yet.
  • Public, mostly read-only, cache-heavy APIs. If most traffic is the same GET request served from a CDN, GraphQL's POST-by-default model works against you. Persisted queries over GET help, but that's extra machinery.
  • Laravel or Express apps with no N+1 discipline. If nobody on the team is going to add batching and depth limits, a GraphQL endpoint can end up slower and easier to abuse than the REST API it replaced.
  • Webhooks, file transfers, payment callbacks. Keep these as plain HTTP endpoints even if the rest of the app uses GraphQL.

Where it does pay off is a product with several clients that keep asking for slightly different shapes of the same data, and a team willing to own the schema properly. If you're not there yet, a well-designed REST API with sensible includes (like Stripe's expand[] or JSON:API's include) gets you surprisingly far.

GraphQL REST API Web Development Architecture
Mayur Dabhi

Mayur Dabhi

Full Stack Developer specializing in Laravel, React, and Node.js. Passionate about building scalable web applications and sharing knowledge with the developer community.