Skip to main content

API Design: REST vs GraphQL Decision Guide

Published: May 24, 2026 Updated: August 30, 2026 Larry Qu 7 min read

REST and GraphQL are both valid API paradigms, but they solve different problems. The common mistake is picking one based on hype or familiarity rather than evaluating the specific needs of your clients and data model. This article covers when each makes sense, how to design each well, and the key differences that should drive your decision.

The Core Tradeoff

REST structures your API around resources. A resource is a URL (/users/42) that responds to a fixed set of HTTP methods. The response shape is defined by the server. Clients get what the endpoint returns.

GraphQL structures your API around a schema. Clients send queries specifying exactly which fields they want. The shape of the response is determined by the query, not the server. One endpoint handles all queries and mutations.

The practical implications:

Concern REST GraphQL
Overfetching Clients get all fields, even unused ones Clients request only what they need
Underfetching Multiple requests for related data Single query fetches nested data
Caching HTTP cache headers work naturally Requires client-side cache (Apollo, etc.)
Type safety Relies on OpenAPI or documentation Schema-enforced, introspectable
Learning curve Low — every developer knows HTTP Higher — schema, resolvers, N+1 issues
Tooling maturity Excellent Strong but more complex
Good fit Mobile apps with varied screen sizes, public APIs, simple CRUD Complex UIs with many data relationships, internal APIs, rapid product iteration

REST: Designing for Clarity

A well-designed REST API is predictable. Every endpoint follows the same conventions, every response has the same shape, and errors are handled consistently.

Resource Modeling

Resources are nouns. The HTTP method carries the action. Nest sub-resources when the child’s identity depends on the parent:

GET    /users              → list users (paginated)
POST   /users              → create user
GET    /users/:id          → get user
PUT    /users/:id          → replace user
PATCH  /users/:id          → partial update
DELETE /users/:id          → delete user

GET    /users/:id/posts    → user's posts (nested resource)
POST   /users/:id/posts    → create post for user

Avoid deep nesting beyond two levels — /users/:id/posts/:pid/comments works but GET /comments?userId=X&postId=Y is often cleaner.

For actions that don’t map to CRUD, use sub-resource paths rather than verbs:

POST /users/:id/activate    → activate account
POST /users/:id/password    → change password (not PATCH /users/:id with partial body)
POST /sessions              → login (creates a session)
DELETE /sessions/current    → logout (deletes current session)

Consistent Response Envelope

A response envelope makes client code simpler — it can always check success before reading data:

// src/lib/respond.js
const ok = (res, data, meta = null) =>
  res.status(200).json({ success: true, data, ...(meta && { meta }) });

const created = (res, data) =>
  res.status(201).json({ success: true, data });

const fail = (res, message, { status = 400, code = null } = {}) =>
  res.status(status).json({ success: false, error: { message, ...(code && { code }) } });

module.exports = { ok, created, fail };

Paginated list response:

{
  "success": true,
  "data": [{ "id": 1, "name": "Alice" }],
  "meta": {
    "page": 2, "limit": 20, "total": 183, "totalPages": 10
  }
}

Filtering, Sorting, and Pagination

Expose these as query parameters with sensible defaults:

// GET /users?role=admin&sort=createdAt&order=desc&page=2&limit=20
const listUsers = async (req, res, next) => {
  try {
    const page   = Math.max(1, parseInt(req.query.page, 10)  || 1);
    const limit  = Math.min(100, parseInt(req.query.limit, 10) || 20);
    const sort   = req.query.sort   || 'createdAt';
    const order  = req.query.order === 'asc' ? 1 : -1;
    const filter = {};
    if (req.query.role) filter.role = req.query.role;

    const [total, users] = await Promise.all([
      User.countDocuments(filter),
      User.find(filter)
        .sort({ [sort]: order })
        .skip((page - 1) * limit)
        .limit(limit)
        .lean()
    ]);

    return ok(res, users, {
      page, limit, total, totalPages: Math.ceil(total / limit)
    });
  } catch (err) { next(err); }
};

Versioning

Version your REST API in the URL path. It’s explicit, easy to route, and allows you to run v1 and v2 simultaneously during migration periods:

const v1 = require('./routes/v1');
const v2 = require('./routes/v2');

app.use('/api/v1', v1);
app.use('/api/v2', v2);

Signal deprecation via headers so clients have advance warning:

const deprecate = (req, res, next) => {
  res.set('Deprecation', 'true');
  res.set('Sunset', 'Fri, 01 Jan 2027 00:00:00 GMT');
  res.set('Link', '</api/v2>; rel="successor-version"');
  next();
};
app.use('/api/v1', deprecate, v1);

GraphQL: When Flexibility Matters

GraphQL’s schema is a contract between server and clients. Every type, field, and relationship is declared upfront. Clients explore it with introspection queries, and every query is validated against the schema before execution.

Schema Design

Define types to model your domain. Use ! (non-null) deliberately — don’t mark everything non-null or you’ll need to handle nulls from the wrong direction:

# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  role: UserRole!
  posts: [Post!]!
  createdAt: String!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  publishedAt: String
}

enum UserRole { ADMIN USER MODERATOR }

type Query {
  users(role: UserRole, page: Int, limit: Int): UserList!
  user(id: ID!): User
  post(id: ID!): Post
}

type UserList {
  items: [User!]!
  total: Int!
  page: Int!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
}

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

input UpdateUserInput {
  name: String
  email: String
}

Use input types for mutation arguments — it keeps the schema clean and makes validation consistent.

Resolvers with DataLoader

Resolvers fetch data for each field. The N+1 problem is the most common GraphQL performance issue: fetching a list of posts, then running a separate SELECT for each post’s author. DataLoader solves this by batching all author lookups into one query:

// src/graphql/loaders.js
const DataLoader = require('dataloader');
const User = require('../models/User');

// Receives an array of IDs, returns a matching array of users (in the same order)
const createUserLoader = () => new DataLoader(async (userIds) => {
  const users = await User.find({ _id: { $in: userIds } }).lean();
  const userMap = new Map(users.map(u => [u._id.toString(), u]));
  return userIds.map(id => userMap.get(id) ?? null);
});

// Create a fresh loader per request to prevent cross-request cache sharing
const createLoaders = () => ({ userLoader: createUserLoader() });
module.exports = createLoaders;
// src/graphql/resolvers.js
const resolvers = {
  Query: {
    users: async (_, { role, page = 1, limit = 20 }, { dataSources }) => {
      const filter = role ? { role } : {};
      const [items, total] = await Promise.all([
        User.find(filter).skip((page - 1) * limit).limit(limit).lean(),
        User.countDocuments(filter)
      ]);
      return { items, total, page };
    },
    user: (_, { id }) => User.findById(id).lean(),
  },

  Post: {
    // Uses the DataLoader — all author fetches in this query are batched into one DB call
    author: (post, _, { loaders }) => loaders.userLoader.load(post.author.toString()),
  },

  Mutation: {
    createUser: async (_, { input }) => {
      const user = await User.create(input);
      return user.toObject();
    },
    deleteUser: async (_, { id }) => {
      await User.findByIdAndDelete(id);
      return true;
    },
  },
};

Connect to Apollo Server with request-scoped loaders:

const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const createLoaders = require('./graphql/loaders');

const server = new ApolloServer({ typeDefs, resolvers });
await server.start();

app.use('/graphql', expressMiddleware(server, {
  context: async ({ req }) => ({
    user: req.user,                // Auth middleware sets this
    loaders: createLoaders(),      // Fresh loader instance per request
  })
}));

REST vs GraphQL: When to Choose Each

The decision comes down to your clients and your data relationships.

Choose REST when:

  • You have a public API with external consumers who need stability and predictability
  • Your data model is mostly independent resources with few cross-entity queries
  • HTTP caching is important (REST caches at the CDN level naturally)
  • Your team is small or the API surface is simple

Choose GraphQL when:

  • You have multiple clients (web, mobile, third-party) with different data needs — they can each request exactly the fields they use
  • Your domain has complex nested relationships that require multiple REST round-trips
  • You’re building an internal API where schema introspection and type safety improve developer velocity
  • You have enough backend complexity to justify learning DataLoader, persisted queries, and caching strategy

Many production systems use both: a public REST API for external partners and a GraphQL API for the internal frontend. The REST surface is stable and documented; the GraphQL surface is flexible and evolves quickly.

Summary

API design quality directly affects developer experience and system performance. The key principles regardless of paradigm:

  • Model your domain clearly before writing any code — correct resource modeling in REST and schema design in GraphQL saves significant refactoring later
  • Keep response shapes consistent — clients should have a single pattern to handle, not endpoint-specific special cases
  • Version from the start; retrofitting versioning is painful
  • In GraphQL, always use DataLoader for any field that resolves related entities — N+1 queries will degrade performance at scale
  • Document with OpenAPI (REST) or rely on schema introspection (GraphQL) — undocumented APIs get misused

Resources

Comments

👍 Was this article helpful?