GraphQL vs REST — API Design
Interview Relevance: High — Interviewers frequently ask when to choose GraphQL over REST. Know the trade-offs cold, and be ready to justify your API design choice with concrete reasons.
GraphQL is a query language for your API and a runtime for executing those queries. Created by Facebook in 2012 and open-sourced in 2015, it solves the core inefficiencies of REST: over-fetching, under-fetching, and rigid endpoint proliferation.
The Restaurant Analogy
The fastest way to internalize GraphQL vs REST is through an analogy.
REST = A Fixed-Menu Restaurant
You walk in, pick "Burger Combo" from the menu. You get:
- Burger ✅ — you wanted this
- Fries ✅ — you didn't want these
- Drink ✅ — you already have one
Want extra cheese? Ask a second waiter (second endpoint). Want the salad from another combo? Ask a third waiter (third endpoint).
GraphQL = A Chef's Table (Custom Order)
You tell the chef exactly what you want:
"Burger patty, cheddar cheese, lettuce — no bun, no fries, no drink."
The chef brings you exactly that. One conversation, one trip, no waste.
The Core Problem GraphQL Solves
Architecture Comparison
Over-fetching vs Under-fetching (Visual)
Key Concepts
1. Schema — The Contract
The schema is the single source of truth. It defines every type, field, and operation the API supports. Think of it as a typed blueprint agreed on by the frontend and backend teams.
# Scalar types — the primitives
# Int, Float, String, Boolean, ID
# Custom object types
type User {
id: ID! # ! = non-null (required)
name: String!
email: String!
posts: [Post!]! # list of non-null Posts
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
text: String!
author: User!
}
# Root types — entry points for the API
type Query {
user(id: ID!): User # nullable — returns null if not found
users: [User!]! # non-null list
post(id: ID!): Post
posts: [Post!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
createPost(title: String!, body: String!, authorId: ID!): Post!
deletePost(id: ID!): Boolean!
}
type Subscription {
newPost: Post! # fires every time a post is created
newComment(postId: ID!): Comment!
}2. Operations
| Operation | REST Equivalent | Purpose |
|---|---|---|
query | GET | Read data |
mutation | POST / PUT / PATCH / DELETE | Write data |
subscription | WebSocket | Real-time stream |
# Query — read exact fields
query GetUserWithPosts {
user(id: "1") {
name # ← only name, not email or other fields
posts {
title # ← only title, not body
}
}
}
# Mutation — write data, get back what you need
mutation AddUser {
createUser(name: "Alice", email: "alice@example.com") {
id
name
}
}
# Subscription — real-time
subscription WatchNewPosts {
newPost {
title
author {
name
}
}
}3. Resolvers — The Execution Engine
A resolver is a function that populates data for each field. The GraphQL runtime calls resolvers only for fields the client actually requested.
GraphQL vs REST — Full Comparison
| Dimension | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple (/users, /posts, /comments) | Single (POST /graphql) |
| Data shape | Fixed — server decides | Flexible — client decides |
| Over-fetching | ✅ Common | ❌ Eliminated |
| Under-fetching | ✅ Common (waterfall requests) | ❌ Eliminated |
| Type system | Optional (OpenAPI/Swagger) | Built-in, mandatory |
| Versioning | URL-based (/v1/, /v2/) | Schema evolution (deprecate fields) |
| Real-time | Polling or manual WebSockets | Native Subscriptions |
| HTTP Caching | ✅ Native (GET caches automatically) | ❌ Manual (Apollo Client, persisted queries) |
| Error handling | HTTP status codes (404, 500) | Always 200; errors in errors[] array |
| Introspection | Via docs / OpenAPI spec | Built-in (__schema query) |
| Tooling maturity | Mature & universal | Growing (Apollo, GraphiQL, Hasura) |
| Learning curve | Low | Medium |
| Best for | Simple CRUD, public APIs | Complex data, multi-client, rapid iteration |
JavaScript Code Examples
Example 1 — REST: The Multi-Trip Problem
// ❌ REST: 3 separate HTTP calls to build one "Post Detail" view
const BASE = "https://jsonplaceholder.typicode.com";
async function getPostDetailWithREST(postId) {
// Trip 1 — get the post
const post = await fetch(`${BASE}/posts/${postId}`).then((r) => r.json());
// Returns: { id, userId, title, body } ← we only need title + userId
// Trip 2 — get the author (need userId from post)
const user = await fetch(`${BASE}/users/${post.userId}`).then((r) =>
r.json()
);
// Returns: { id, name, username, email, address, phone, website, company }
// We need: name — everything else is WASTED bandwidth
// Trip 3 — get comments
const comments = await fetch(`${BASE}/posts/${postId}/comments`).then((r) =>
r.json()
);
// We only need the count — but we fetched full objects for all comments
return {
title: post.title, // used
authorName: user.name, // used (1 field out of 8+)
commentCount: comments.length, // used (only length, not the data)
};
}
getPostDetailWithREST(1).then(console.log);
// { title: 'sunt aut facere...', authorName: 'Leanne Graham', commentCount: 5 }
// Cost: 3 HTTP round trips 😞Example 2 — GraphQL Server (Node.js + Apollo Server)
// npm install @apollo/server graphql
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
// ── Fake in-memory database ────────────────────────────────────────────────
const db = {
users: [
{ id: "1", name: "Alice Johnson", email: "alice@example.com" },
{ id: "2", name: "Bob Smith", email: "bob@example.com" },
],
posts: [
{
id: "1",
title: "GraphQL is Amazing",
body: "Here is why...",
authorId: "1",
},
{ id: "2", title: "REST vs GraphQL", body: "A comparison.", authorId: "1" },
{
id: "3",
title: "Node.js Performance",
body: "Tips & tricks.",
authorId: "2",
},
],
comments: [
{ id: "1", text: "Great post!", postId: "1", authorId: "2" },
{ id: "2", text: "Very helpful", postId: "1", authorId: "2" },
{ id: "3", text: "Loved this!", postId: "2", authorId: "1" },
],
};
// ── Schema ─────────────────────────────────────────────────────────────────
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
postCount: Int!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments: [Comment!]!
commentCount: Int!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
}
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
}
type Mutation {
createUser(name: String!, email: String!): User!
createPost(title: String!, body: String!, authorId: ID!): Post!
addComment(text: String!, postId: ID!, authorId: ID!): Comment!
}
`;
// ── Resolvers ──────────────────────────────────────────────────────────────
const resolvers = {
Query: {
users: () => db.users,
user: (_, { id }) => db.users.find((u) => u.id === id),
posts: () => db.posts,
post: (_, { id }) => db.posts.find((p) => p.id === id),
},
// Field-level resolvers — only invoked if client requests these fields
User: {
posts: (parent) => db.posts.filter((p) => p.authorId === parent.id),
postCount: (parent) =>
db.posts.filter((p) => p.authorId === parent.id).length,
},
Post: {
author: (parent) => db.users.find((u) => u.id === parent.authorId),
comments: (parent) => db.comments.filter((c) => c.postId === parent.id),
commentCount: (parent) =>
db.comments.filter((c) => c.postId === parent.id).length,
},
Comment: {
author: (parent) => db.users.find((u) => u.id === parent.authorId),
post: (parent) => db.posts.find((p) => p.id === parent.postId),
},
Mutation: {
createUser: (_, { name, email }) => {
const user = { id: String(db.users.length + 1), name, email };
db.users.push(user);
return user;
},
createPost: (_, { title, body, authorId }) => {
const post = { id: String(db.posts.length + 1), title, body, authorId };
db.posts.push(post);
return post;
},
addComment: (_, { text, postId, authorId }) => {
const comment = {
id: String(db.comments.length + 1),
text,
postId,
authorId,
};
db.comments.push(comment);
return comment;
},
},
};
// ── Start ──────────────────────────────────────────────────────────────────
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 GraphQL ready at: ${url}`);
// Visit http://localhost:4000 → Apollo Sandbox (interactive query editor)Example 3 — GraphQL Client (Vanilla JS)
// ✅ One utility function replaces all REST fetch calls
const GQL_URL = "http://localhost:4000/";
async function gql(query, variables = {}) {
const res = await fetch(GQL_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
const { data, errors } = await res.json();
if (errors?.length) throw new Error(errors[0].message);
return data;
}
// ── Query 1: Only user names — no over-fetching ──────────────────────────
const { users } = await gql(`
query {
users {
name # ← email, id, posts not fetched — server skips those resolvers
}
}
`);
console.log(users);
// [{ name: 'Alice Johnson' }, { name: 'Bob Smith' }]
// ── Query 2: Post + author + comments in ONE request ─────────────────────
const { post } = await gql(
`
query GetPost($id: ID!) {
post(id: $id) {
title
body
author { name email }
comments { text author { name } }
commentCount
}
}
`,
{ id: "1" }
);
console.log(post);
// {
// title: 'GraphQL is Amazing',
// body: 'Here is why...',
// author: { name: 'Alice Johnson', email: 'alice@example.com' },
// comments: [
// { text: 'Great post!', author: { name: 'Bob Smith' } },
// { text: 'Very helpful', author: { name: 'Bob Smith' } }
// ],
// commentCount: 2
// }
// 🎉 All in ONE HTTP request!
// ── Mutation: Create user ─────────────────────────────────────────────────
const { createUser } = await gql(
`
mutation CreateUser($name: String!, $email: String!) {
createUser(name: $name, email: $email) {
id
name
}
}
`,
{ name: "Charlie", email: "charlie@example.com" }
);
console.log(createUser); // { id: '3', name: 'Charlie' }
// ── Dashboard: Multiple resources, one round trip ─────────────────────────
const dashboard = await gql(`
query Dashboard {
users { name postCount }
posts { title commentCount author { name } }
}
`);
console.log(dashboard);
// users and posts fetched in a single round trip ✅Example 4 — Real-Time Subscriptions
// Server: Add PubSub to mutations + subscription resolvers
// npm install graphql-subscriptions graphql-ws ws
import { PubSub } from "graphql-subscriptions";
const pubsub = new PubSub();
const typeDefs = `#graphql
# ...existing types...
type Subscription {
newPost: Post!
newComment(postId: ID!): Comment!
}
`;
const resolvers = {
// ...existing resolvers...
Subscription: {
newPost: {
// asyncIterator fires whenever 'NEW_POST' is published
subscribe: () => pubsub.asyncIterator(["NEW_POST"]),
},
newComment: {
subscribe: (_, { postId }) =>
pubsub.asyncIterator([`NEW_COMMENT_${postId}`]),
},
},
Mutation: {
createPost: (_, args) => {
const post = { id: String(db.posts.length + 1), ...args };
db.posts.push(post);
pubsub.publish("NEW_POST", { newPost: post }); // ← Notify all subscribers
return post;
},
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Client: Subscribe to new posts in real time
// npm install graphql-ws
import { createClient } from "graphql-ws";
const client = createClient({ url: "ws://localhost:4000/graphql" });
client.subscribe(
{
query: `
subscription {
newPost {
title
author { name }
}
}
`,
},
{
next: ({ data }) => console.log("📬 New post:", data.newPost),
error: (err) => console.error("Error:", err),
complete: () => console.log("Subscription ended"),
}
);
// When any client runs createPost mutation → this fires instantly via WebSocketExample 5 — The N+1 Problem and DataLoader Fix
A critical interview topic. Without batching, querying N posts will trigger N+1 database calls (1 for posts + 1 per author).
// npm install dataloader
import DataLoader from 'dataloader';
// ❌ Without DataLoader — called once per post (N calls)
Post: {
author: (parent) => db.users.find(u => u.id === parent.authorId),
}
// ✅ With DataLoader — all IDs collected, then ONE batched query
const userLoader = new DataLoader(async (userIds) => {
// userIds = ['1', '2', '3', ...] — all collected in one tick
console.log('Batch fetching users:', userIds); // Only logged ONCE
const users = await dbQuery(`SELECT * FROM users WHERE id IN (${userIds.join(',')})`);
// CRITICAL: must return in same order as input IDs
return userIds.map(id => users.find(u => u.id === id));
});
// Updated resolver — load() calls are batched automatically
Post: {
author: (parent) => userLoader.load(parent.authorId),
// ↑ queued, not executed immediately
}
// Create one loader per request (avoid cross-request data leaks)
const resolvers = {
Query: {
posts: (_, __, context) => {
// context.loaders is a fresh set of DataLoaders per request
return db.posts;
}
},
Post: {
author: (parent, _, context) => context.loaders.user.load(parent.authorId),
}
};When to Use GraphQL vs REST
Use GraphQL When:
| Scenario | Reason |
|---|---|
| Multiple client types (mobile + web) | Each client specifies its own data shape |
| Complex/nested relational data | Traverse relationships in one query |
| Rapid feature iteration | Deprecate fields without versioning |
| Real-time features (chat, feeds) | Native subscription support |
| Multiple teams sharing one API | Strong typed schema = clear contract |
| Aggregating multiple data sources | Schema stitching / federation |
Use REST When:
| Scenario | Reason |
|---|---|
| Simple CRUD with clear resource boundaries | Less complexity, HTTP verbs map naturally |
| Public APIs for external developers | Universal familiarity |
| File uploads / multipart data | Better native HTTP support |
| Heavy GET-based caching needed | CDN/browser caching works out of the box |
| Team is unfamiliar with GraphQL | Lower operational overhead |
| Microservices with strict boundaries | One service = one REST resource |
💡 Pro tip for interviews: Many production systems use both — REST for public-facing or file-heavy APIs, GraphQL for the internal data layer powering mobile and web clients.
Error Handling: A Key Difference
// ── REST Errors: HTTP status codes ────────────────────────────────────────
// 200 OK, 400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Server Error
const res = await fetch("/api/users/999");
if (!res.ok) {
console.error(`Error: ${res.status}`); // 404
}
// ── GraphQL Errors: Always 200, errors in payload ─────────────────────────
// HTTP status is almost always 200 even on errors!
const { data, errors } = await gql(`
query { user(id: "999") { name } }
`);
// data can be PARTIAL — some fields succeed, some fail
// {
// "data": { "user": null }, ← query "succeeded"
// "errors": [{
// "message": "User not found",
// "locations": [{ "line": 2, "column": 3 }],
// "path": ["user"],
// "extensions": { "code": "NOT_FOUND" }
// }]
// }
// Always check both data AND errors in GraphQL
if (errors) {
errors.forEach((e) => console.error(e.message));
}GraphQL Federation (Advanced)
For microservices, GraphQL Federation lets multiple services expose their own schemas, which a Gateway composes into one unified supergraph.
Each service owns its part of the schema. The gateway stitches them together transparently. The client never knows it's talking to multiple services.
Quick Reference Cheat Sheet
GraphQL Operations
══════════════════════════════════════════════════════════════
query → Read data (= GET in REST)
mutation → Write / delete data (= POST/PUT/DELETE)
subscription → Real-time stream (= WebSocket)
GraphQL Type Modifiers
══════════════════════════════════════════════════════════════
String nullable string (can return null)
String! non-null string (always returns value)
[String] nullable list of nullable strings
[String!]! non-null list of non-null strings
GraphQL Response Shape (always)
══════════════════════════════════════════════════════════════
{
"data": { ... }, ← present on success (or partial success)
"errors": [ { ... } ] ← present on failure (HTTP status still 200!)
}
Resolver Signature
══════════════════════════════════════════════════════════════
(parent, args, context, info) => value
parent → resolved parent object
args → query arguments e.g. user(id: "1") → args.id
context → shared per-request data (auth, DataLoaders, DB)
info → AST info (rarely used)Ecosystem & Tooling
| Tool | Purpose | Notes |
|---|---|---|
| Apollo Server | GraphQL server (Node.js) | Most popular, great DX |
| Apollo Client | GraphQL client (React/JS) | Caching, optimistic UI |
| GraphiQL | In-browser query IDE | Auto-introspects schema |
| Apollo Studio | Schema registry, monitoring | Production-grade observability |
| Hasura | Instant GraphQL over Postgres | Auto-generates schema from DB |
| Prisma | Type-safe ORM for Node.js | Pairs perfectly with Apollo |
| DataLoader | Batching + caching for resolvers | Solves the N+1 problem |
| graphql-ws | WebSocket transport for Subscriptions | RFC-compliant protocol |
| Pothos | Code-first schema builder | TypeScript-first, no codegen |
| GraphQL Codegen | Generate TS types from schema | Type-safe clients automatically |
Interview Quick-Fire Answers
Q: What is GraphQL?
A query language for APIs and a runtime for executing those queries. The client specifies exactly what data it needs, and the server returns precisely that — no more, no less.
Q: What is over-fetching and under-fetching?
Over-fetching: API returns more data than the client needs (wasted bandwidth). Under-fetching: one API call isn't enough, so the client must make multiple sequential requests (waterfall round trips).
Q: How does GraphQL handle versioning?
It doesn't need it. New fields are added to the schema and old ones are deprecated (marked with
@deprecated). Clients using old fields continue to work; new clients use new fields. No/v2endpoints.
Q: What is the N+1 problem in GraphQL?
When resolving a list of N items, each item triggers an additional database query for a related field — resulting in N+1 total DB calls. Solved with DataLoader, which batches all related IDs into a single query.
Q: When would you choose REST over GraphQL?
Public APIs (universal familiarity), simple CRUD with clear resource boundaries, heavy GET-based caching (CDN/browser), file uploads, or when the team is unfamiliar with GraphQL tooling.
Q: What is GraphQL Federation?
An architecture where multiple microservices each expose a partial schema, and an Apollo Gateway composes them into a single unified supergraph. The client sees one API; the gateway routes queries to the right service.
