Skip to content

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.

graphql
# 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

OperationREST EquivalentPurpose
queryGETRead data
mutationPOST / PUT / PATCH / DELETEWrite data
subscriptionWebSocketReal-time stream
graphql
# 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

DimensionRESTGraphQL
EndpointsMultiple (/users, /posts, /comments)Single (POST /graphql)
Data shapeFixed — server decidesFlexible — client decides
Over-fetching✅ Common❌ Eliminated
Under-fetching✅ Common (waterfall requests)❌ Eliminated
Type systemOptional (OpenAPI/Swagger)Built-in, mandatory
VersioningURL-based (/v1/, /v2/)Schema evolution (deprecate fields)
Real-timePolling or manual WebSocketsNative Subscriptions
HTTP Caching✅ Native (GET caches automatically)❌ Manual (Apollo Client, persisted queries)
Error handlingHTTP status codes (404, 500)Always 200; errors in errors[] array
IntrospectionVia docs / OpenAPI specBuilt-in (__schema query)
Tooling maturityMature & universalGrowing (Apollo, GraphiQL, Hasura)
Learning curveLowMedium
Best forSimple CRUD, public APIsComplex data, multi-client, rapid iteration

JavaScript Code Examples

Example 1 — REST: The Multi-Trip Problem

js
// ❌ 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)

js
// 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)

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

js
// 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 WebSocket

Example 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).

js
// 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:

ScenarioReason
Multiple client types (mobile + web)Each client specifies its own data shape
Complex/nested relational dataTraverse relationships in one query
Rapid feature iterationDeprecate fields without versioning
Real-time features (chat, feeds)Native subscription support
Multiple teams sharing one APIStrong typed schema = clear contract
Aggregating multiple data sourcesSchema stitching / federation

Use REST When:

ScenarioReason
Simple CRUD with clear resource boundariesLess complexity, HTTP verbs map naturally
Public APIs for external developersUniversal familiarity
File uploads / multipart dataBetter native HTTP support
Heavy GET-based caching neededCDN/browser caching works out of the box
Team is unfamiliar with GraphQLLower operational overhead
Microservices with strict boundariesOne 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

js
// ── 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

ToolPurposeNotes
Apollo ServerGraphQL server (Node.js)Most popular, great DX
Apollo ClientGraphQL client (React/JS)Caching, optimistic UI
GraphiQLIn-browser query IDEAuto-introspects schema
Apollo StudioSchema registry, monitoringProduction-grade observability
HasuraInstant GraphQL over PostgresAuto-generates schema from DB
PrismaType-safe ORM for Node.jsPairs perfectly with Apollo
DataLoaderBatching + caching for resolversSolves the N+1 problem
graphql-wsWebSocket transport for SubscriptionsRFC-compliant protocol
PothosCode-first schema builderTypeScript-first, no codegen
GraphQL CodegenGenerate TS types from schemaType-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 /v2 endpoints.

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.

Released under the ISC License.