Skip to main content

GraphQL vs REST: A Complete Guide to Choosing the Right API Paradigm

Alex Rivera
14 min read
GraphQL vs REST: A Complete Guide to Choosing the Right API Paradigm

For over two decades, REST (Representational State Transfer) has been the de facto standard for web APIs. But the rise of mobile apps, complex frontends, and microservices architectures gave birth to GraphQL—a query language that promises to solve REST's biggest pain points.

Should you abandon REST for GraphQL? The answer is nuanced. Let's break down the technical trade-offs.

The Evolution of API Paradigms

API Evolution Timeline
──────────────────────────────────────────────────────────────────

1990s        2000s          2010           2015           2020s
  │            │              │              │              │
SOAP/XML → REST/JSON → Mobile Explosion → GraphQL → Federation
                              │              │
                              └── Problems ──┘
                                  emerged

Key Inflection Points:
├── 2000: REST formalized by Roy Fielding
├── 2010: Mobile apps need efficient data fetching
├── 2012: Facebook creates GraphQL internally
├── 2015: GraphQL open-sourced
├── 2020: Apollo Federation for microservices
└── 2023: Both REST and GraphQL thrive for different use cases

Understanding REST

REST is resource-oriented. You have predictable endpoints that represent entities:

REST API Structure
──────────────────────────────────────────────────────────────────

Endpoint                     Method    Purpose
────────────────────────────────────────────────────────────────
/users                       GET       List all users
/users/{id}                  GET       Get single user
/users                       POST      Create user
/users/{id}                  PUT       Replace user
/users/{id}                  PATCH     Update user fields
/users/{id}                  DELETE    Delete user
/users/{id}/posts            GET       List user's posts
/users/{id}/posts/{postId}   GET       Get specific post

REST Example

// GET /users/123
const response = await fetch('/api/users/123');
const user = await response.json();

// Response
{
  "id": "123",
  "name": "John Doe",
  "email": "john@example.com",
  "avatar": "https://...",
  "address": {
    "street": "123 Main St",
    "city": "San Francisco",
    "state": "CA",
    "zip": "94102"
  },
  "createdAt": "2024-01-15T10:30:00Z",
  "lastLogin": "2024-06-18T08:45:00Z",
  "preferences": {
    "theme": "dark",
    "notifications": true,
    "language": "en"
  }
}

REST Problems at Scale

ProblemExampleImpact
Over-fetchingNeed just name, get entire user objectWasted bandwidth, slower mobile
Under-fetchingNeed user + posts = 2 requestsWaterfall latency, complexity
N+1 Problem10 users → 10 requests for their posts11 round trips
Versioning/v1/users, /v2/usersMaintenance burden
DocumentationSwagger/OpenAPI separate from codeCan drift from reality

Understanding GraphQL

GraphQL is a query language. You ask for exactly what you need:

# Single request gets exactly what you need
query GetUserWithPosts {
  user(id: "123") {
    name
    email
    posts(first: 5) {
      title
      excerpt
      createdAt
    }
  }
}

GraphQL Response

{
  "data": {
    "user": {
      "name": "John Doe",
      "email": "john@example.com",
      "posts": [
        {
          "title": "Introduction to GraphQL",
          "excerpt": "GraphQL is a query language...",
          "createdAt": "2024-06-15"
        },
        {
          "title": "REST vs GraphQL",
          "excerpt": "When should you use each...",
          "createdAt": "2024-06-10"
        }
      ]
    }
  }
}

GraphQL Schema Definition

type User {
  id: ID!
  name: String!
  email: String!
  avatar: String
  posts(first: Int, after: String): PostConnection!
  followers: [User!]!
  following: [User!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  excerpt: String
  author: User!
  comments(first: Int): CommentConnection!
  likes: Int!
  publishedAt: DateTime
}

type Query {
  user(id: ID!): User
  users(first: Int, after: String, filter: UserFilter): UserConnection!
  post(id: ID!): Post
  posts(first: Int, after: String, filter: PostFilter): PostConnection!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  createPost(input: CreatePostInput!): Post!
  likePost(id: ID!): Post!
}

Head-to-Head Comparison

Data Fetching Efficiency

Scenario: Display user profile with 5 recent posts
──────────────────────────────────────────────────────────────────

REST Approach:
1. GET /users/123           → 1 request (200ms)
2. GET /users/123/posts     → 1 request (150ms)
Total: 2 requests, 350ms, ~15KB transferred

GraphQL Approach:
1. POST /graphql            → 1 request (250ms)
Total: 1 request, 250ms, ~3KB transferred

Savings: 28% faster, 80% less data

Caching Complexity

REST Caching (Simple)
──────────────────────────────────────────────────────────────────

Client ──► CDN ──► Origin
          │
          └── Cache: /users/123 → response
              TTL: 1 hour
              Invalidation: Easy (purge specific URL)

HTTP headers handle everything:
Cache-Control: public, max-age=3600
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"


GraphQL Caching (Complex)
──────────────────────────────────────────────────────────────────

Client ──► CDN (can't cache POST) ──► Origin
                                      │
                    ┌─────────────────┘
                    ▼
            Application Cache
            ├── Normalized cache (Apollo Client)
            ├── Persisted queries (hash → query)
            └── Cache-by-ID strategies

Requires:
├── Client-side cache normalization
├── Persisted queries for CDN caching
└── Cache invalidation is still hard

Security Considerations

GraphQL Security Challenges
──────────────────────────────────────────────────────────────────

1. Query Depth Attack
─────────────────────
query Evil {
  user(id: "1") {
    posts {
      author {
        posts {
          author {
            posts {  # Infinite nesting...
            }
          }
        }
      }
    }
  }
}

Mitigation: Query depth limiting (e.g., max depth = 5)


2. Query Complexity Attack
─────────────────────────
query ExpensiveQuery {
  users(first: 1000) {
    posts(first: 100) {
      comments(first: 100) {
        author {
          followers(first: 100) {
            # 1000 × 100 × 100 × 100 = 1 billion records
          }
        }
      }
    }
  }
}

Mitigation: Query complexity analysis and limits


3. Introspection in Production
─────────────────────────────
query IntrospectionQuery {
  __schema {
    types {
      name
      fields {
        name
      }
    }
  }
}

Mitigation: Disable introspection in production

REST Security (Generally Simpler)

// Rate limiting by endpoint is straightforward
const rateLimits = {
  "GET /users": { requests: 1000, window: "1h" },
  "POST /users": { requests: 10, window: "1h" },
  "GET /posts": { requests: 2000, window: "1h" },
};

// Authorization is resource-based
app.get("/users/:id", authorize("users:read"), getUser);
app.put("/users/:id", authorize("users:write"), updateUser);

Implementation Comparison

REST with Next.js API Routes

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const user = await db.users.findUnique({
    where: { id: params.id },
    include: {
      posts: {
        take: 5,
        orderBy: { createdAt: "desc" },
      },
    },
  });

  if (!user) {
    return NextResponse.json({ error: "User not found" }, { status: 404 });
  }

  return NextResponse.json(user);
}

export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await request.json();

  const user = await db.users.update({
    where: { id: params.id },
    data: body,
  });

  return NextResponse.json(user);
}

GraphQL with Apollo Server

// graphql/resolvers.ts
import { Resolvers } from "./generated/graphql";

const resolvers: Resolvers = {
  Query: {
    user: async (_, { id }, context) => {
      return context.dataSources.users.getUser(id);
    },
    users: async (_, { first, after, filter }, context) => {
      return context.dataSources.users.getUsers({ first, after, filter });
    },
  },

  User: {
    // Field-level resolver - only runs if field is requested
    posts: async (user, { first }, context) => {
      return context.dataSources.posts.getPostsByUser(user.id, { first });
    },
    followers: async (user, _, context) => {
      return context.dataSources.users.getFollowers(user.id);
    },
  },

  Mutation: {
    createUser: async (_, { input }, context) => {
      return context.dataSources.users.createUser(input);
    },
    likePost: async (_, { id }, context) => {
      return context.dataSources.posts.likePost(id, context.userId);
    },
  },
};

// DataLoader for N+1 prevention
class PostsDataSource {
  private loader = new DataLoader(async (userIds: string[]) => {
    const posts = await db.posts.findMany({
      where: { authorId: { in: userIds } },
    });

    // Group posts by author
    const postsByUser = new Map<string, Post[]>();
    for (const post of posts) {
      const userPosts = postsByUser.get(post.authorId) || [];
      userPosts.push(post);
      postsByUser.set(post.authorId, userPosts);
    }

    return userIds.map((id) => postsByUser.get(id) || []);
  });

  getPostsByUser(userId: string, { first }: { first?: number }) {
    return this.loader.load(userId).then((posts) => posts.slice(0, first));
  }
}

Decision Framework

When to Use REST

ScenarioWhy REST
Simple CRUD operationsLess overhead, predictable patterns
Public APIsEasier for external developers to understand
Heavy caching needsHTTP caching is mature and well-supported
File uploadsMultipart forms are straightforward
Team new to GraphQLLearning curve is real
Microservices with clear boundariesEach service owns its resources

When to Use GraphQL

ScenarioWhy GraphQL
Mobile applicationsMinimize data transfer and round trips
Complex, nested dataSingle request for deep object graphs
Rapidly evolving frontendFrontend can request new fields without backend changes
Multiple client typesWeb, mobile, TV apps need different data shapes
API gateway/BFF patternAggregate multiple microservices
Strong typing requirementsSchema is the contract

Decision Flowchart

GraphQL vs REST Decision Tree
──────────────────────────────────────────────────────────────────

Start
  │
  ├── Is this a public API for external developers?
  │     ├── Yes → REST (more familiar, easier to document)
  │     └── No ──┐
  │              │
  ├─────────────┘
  │
  ├── Do you have multiple client types (web, mobile, etc.)?
  │     ├── Yes → Consider GraphQL
  │     └── No ──┐
  │              │
  ├─────────────┘
  │
  ├── Is your data highly relational with deep nesting?
  │     ├── Yes → GraphQL shines here
  │     └── No ──┐
  │              │
  ├─────────────┘
  │
  ├── Do you need aggressive HTTP caching at CDN level?
  │     ├── Yes → REST (simpler caching)
  │     └── No ──┐
  │              │
  ├─────────────┘
  │
  ├── Does your team have GraphQL experience?
  │     ├── No → REST (lower learning curve)
  │     └── Yes → Either works, choose based on other factors
  │
  └── Final Decision: Consider using BOTH for different use cases

Hybrid Approaches

Many successful architectures use both:

Hybrid Architecture Example
──────────────────────────────────────────────────────────────────

                    ┌─────────────────────────────────────┐
                    │           API Gateway               │
                    │      (Kong, AWS API Gateway)        │
                    └──────────────┬──────────────────────┘
                                   │
              ┌────────────────────┼────────────────────┐
              │                    │                    │
              ▼                    ▼                    ▼
     ┌────────────────┐   ┌────────────────┐   ┌────────────────┐
     │   REST APIs    │   │  GraphQL BFF   │   │  Webhooks      │
     │                │   │                │   │                │
     │ • Public APIs  │   │ • Mobile app   │   │ • Event        │
     │ • Webhooks     │   │ • Web app      │   │   notifications│
     │ • Simple CRUD  │   │ • Aggregation  │   │ • Integrations │
     └────────────────┘   └────────────────┘   └────────────────┘
              │                    │                    │
              └────────────────────┼────────────────────┘
                                   │
                    ┌──────────────┴──────────────┐
                    │                             │
                    ▼                             ▼
           ┌───────────────┐             ┌───────────────┐
           │ Microservice A│             │ Microservice B│
           │   (REST)      │             │   (REST)      │
           └───────────────┘             └───────────────┘

GraphQL Federation

For large-scale systems, Apollo Federation allows composing multiple GraphQL services:

# Users Subgraph
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
}

extend type Query {
  user(id: ID!): User
}

# Posts Subgraph
type Post @key(fields: "id") {
  id: ID!
  title: String!
  author: User!
}

extend type User @key(fields: "id") {
  id: ID! @external
  posts: [Post!]!
}

# Gateway composes them into unified graph

Performance Comparison

Performance Benchmarks (Typical Scenarios)
──────────────────────────────────────────────────────────────────

Scenario 1: Fetch user profile (simple)
├── REST:    ~50ms (1 request)
├── GraphQL: ~55ms (1 request, slightly more parsing)
└── Winner:  Tie

Scenario 2: Fetch user + posts + comments (nested)
├── REST:    ~300ms (3 sequential requests)
├── GraphQL: ~120ms (1 request with nested resolvers)
└── Winner:  GraphQL (2.5x faster)

Scenario 3: Heavy caching scenario
├── REST:    ~10ms (CDN cache hit)
├── GraphQL: ~80ms (app-level cache, no CDN)
└── Winner:  REST (8x faster with cache)

Scenario 4: Mobile with poor connectivity
├── REST:    ~2000ms (multiple round trips compound latency)
├── GraphQL: ~800ms (single request, less data)
└── Winner:  GraphQL (2.5x faster, less battery)

Key Takeaways

  1. Neither is universally better—context determines the right choice
  2. REST excels at simple CRUD, public APIs, and cacheable resources
  3. GraphQL excels at complex data needs, mobile apps, and multi-client scenarios
  4. Security requires attention in both, but GraphQL needs explicit safeguards
  5. Caching is simpler with REST—GraphQL requires more sophisticated strategies
  6. Hybrid approaches work well—use REST for simple services, GraphQL for aggregation
  7. Team experience matters—don't adopt GraphQL without training
  8. Start with REST if uncertain—you can always add GraphQL later

The best API is the one that serves your users and developers effectively. Sometimes that's REST, sometimes GraphQL, and often it's both working together.


Need help designing your API architecture? Contact EGI Consulting for expert guidance on REST, GraphQL, API gateways, and developer experience optimization.

Related articles

Keep reading with a few hand-picked posts based on similar topics.

Posted in Blog & Insights