For more than two decades, Representational State Transfer (REST) has been the de facto architectural standard for web APIs. But as mobile apps and client-rich frontends demanded complex, deeply nested relational data across variable screen sizes, the limitations of rigid REST endpoints became painfully apparent.
In response, Facebook open-sourced GraphQL: a strongly typed query language and runtime engine that allows clients to declare exactly what data they need, receiving a predictably shaped JSON response in a single network round-trip. But is GraphQL a replacement for REST, or does it introduce new architectural baggage? In this guide, we analyze both architectures from an engineering perspective.
1. Over-Fetching and Under-Fetching in REST
The core problem that motivated GraphQL is data mismatches in client requirements:
- Over-Fetching: A mobile screen only needs a user's avatar and username, but
GET /api/users/123returns 45 properties including address, billing details, and hashed metadata, wasting cellular bandwidth. - Under-Fetching (The N+1 Network Problem): To render a user profile with their recent articles and comments, a client must make 3 sequential HTTP calls:
GET /api/users/123GET /api/users/123/articlesGET /api/articles/456/comments
# Client requests exact nested schema in a single HTTP request:
query GetUserProfileWithArticles($userId: ID!) {
user(id: $userId) {
username
avatarUrl
articles(limit: 5) {
title
slug
publishedAt
commentCount
}
}
}
2. The Architecture of Resolvers and DataLoader
In GraphQL, every field in your schema is mapped to a backend Resolver function. While this provides great modularity, naive resolvers introduce the dangerous N+1 Database Query Problem:
const DataLoader = require("dataloader");
// Batch loading function: Groups individual IDs into a single SQL 'IN' query
const userBatchLoader = new DataLoader(async (userIds) => {
console.log(`[Batch SQL] Fetching ${userIds.length} users in one query`);
// SELECT * FROM users WHERE id IN ($1, $2, $3...)
const users = await db.users.findMany({
where: { id: { in: userIds } }
});
// Return array mapped in the exact order of requested IDs
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id));
});
// Resolver implementation:
const resolvers = {
Article: {
author: (article) => {
// DataLoader debounces and batches calls within the same event loop tick!
return userBatchLoader.load(article.authorId);
}
}
};
3. HTTP Caching: REST's Secret Weapon
The biggest architectural advantage of REST over GraphQL is native HTTP caching:
- REST: Each resource has a unique URL (e.g.,
/api/articles/101). CDNs (Cloudflare, Fastly), reverse proxies, and browser caches natively cacheGETresponses using standardCache-ControlandETagheaders. - GraphQL: Almost all requests are dispatched as
POSTrequests to a single endpoint (/graphql). Standard HTTP caches cannot cache POST requests, forcing GraphQL architectures to rely on complex client-side normalized caches (Apollo Client, Urql) or Persisted Queries.
4. Architectural Comparison Matrix
| Feature | REST API | GraphQL |
|---|---|---|
| Data Fetching | Fixed server-defined payloads | Client-driven granular selection |
| Edge CDN Caching | Native, out-of-the-box | Requires Persisted Queries or Apollo Engine |
| Schema & Type Safety | Optional (OpenAPI/Swagger) | Strict, built-in SDL schema contract |
| Security Vulnerability | Standard auth/rate limiting | Deeply nested recursive query DoS attacks |
Frequently Asked Questions (FAQ)
Q: How do you protect GraphQL against malicious query depth attacks?
Enforce query depth limits and query complexity analysis (e.g., using graphql-depth-limit). If an adversary sends a malicious recursive query (e.g., user { friends { friends { friends... } } }), the server calculates complexity before execution and rejects it immediately.
Q: When should I choose REST over GraphQL?
Choose REST for simple CRUD resources, high-throughput public APIs where edge CDN caching is paramount, or file upload/streaming microservices. Choose GraphQL for complex dashboards, mobile apps with variable bandwidth, and microservice aggregation layers.
Conclusion
Neither REST nor GraphQL is universally superior. Modern enterprise architecture often pairs both: using REST for high-performance edge microservices, and GraphQL as a Backend-For-Frontend (BFF) aggregation gateway providing frontends with flexible, unified data access.
💡 Engineering Key Takeaway
Resolve the N+1 database problem using DataLoader batching, and analyze query depth complexity to protect GraphQL backends from DoS attacks.
DataLoader Implementation: Batching Database Lookups
Prevent N+1 query disasters by batching individual resolver calls:
import DataLoader from 'dataloader';
import { db } from './database';
// Batches 50 individual author lookups into 1 SQL query
export const authorLoader = new DataLoader(async (authorIds: readonly string[]) => {
const authors = await db.query(
'SELECT * FROM authors WHERE id = ANY($1)',
[authorIds]
);
// Re-map results to match the exact order of keys
return authorIds.map(id => authors.find(a => a.id === id));
});
GraphQL Security: Query Depth Limiting & DoS Prevention
Because GraphQL allows clients to define arbitrary query shapes, malicious actors can construct deeply nested recursive queries that exhaust backend memory and database connection pools:
# Malicious Nested Query (DoS Attack Vector)
query MaliciousRecursion {
author {
books {
author {
books {
author {
books { ... }
}
}
}
}
}
}
Production GraphQL servers protect themselves by enforcing Query Depth Limiting (rejecting queries deeper than 5–7 levels) and Query Cost Analysis (assigning point costs to fields and rejecting queries exceeding an execution budget).
Schema Stitching vs Apollo Federation
When multiple teams build GraphQL services, Apollo Federation enables a declarative architecture where each team owns a subgraph, and a centralized router (Apollo Gateway) composes them into a unified supergraph.