Skip to content

GraphQL vs REST: Choosing the Right API Style

GraphQL isn't a REST replacement — it's an alternative with different trade-offs that matter depending on your client complexity, team size, and data shape.

3 min read
Side-by-side comparison of GraphQL query and REST endpoint for the same data

The GraphQL vs. REST debate often generates more heat than light. Teams adopt GraphQL because it's trendy, then struggle with caching, authorization, and N+1 queries. Others dismiss it entirely and build REST endpoints with 15 query parameters to avoid over-fetching. The right choice depends on your specific constraints — client diversity, data relationships, and team expertise.

The Core Difference

REST organizes APIs around resources with fixed response shapes. GraphQL gives clients a query language to request exactly the data they need.

tstypescript
// REST — multiple requests, potentially over-fetching
// GET /api/users/123
// GET /api/users/123/orders?limit=5
// GET /api/users/123/reviews?limit=3
 
// Each endpoint returns its full schema, including fields the client doesn't use
 
// GraphQL — one request, exact data
const query = `
  query UserDashboard($userId: ID!) {
    user(id: $userId) {
      name
      avatar
      orders(limit: 5) {
        id
        total
        status
      }
      reviews(limit: 3) {
        rating
        comment
      }
    }
  }
`;

With REST, the server decides what data to return. With GraphQL, the client decides. This shifts complexity from the client to the server.

Where REST Wins

REST is simpler for straightforward CRUD operations with well-defined resources.

tstypescript
// ✅ REST excels at simple, resource-oriented APIs
// Clear, cacheable, easy to understand
app.get("/api/products/:id", getProduct);
app.post("/api/products", createProduct);
app.patch("/api/products/:id", updateProduct);
app.delete("/api/products/:id", deleteProduct);
 
// HTTP caching works out of the box
// GET /api/products/123
// Cache-Control: public, max-age=300
// ETag: "abc123"
tstypescript
// ❌ GraphQL adds complexity for simple operations
const CREATE_PRODUCT = gql`
  mutation CreateProduct($input: CreateProductInput!) {
    createProduct(input: $input) {
      id
      name
      price
    }
  }
`;
 
// Every request is POST, every response is 200
// HTTP caching doesn't work without additional infrastructure
// Error handling is an envelope format, not HTTP status codes

REST advantages: HTTP caching, simpler tooling, status code semantics, smaller learning curve, CDN compatibility.

Where GraphQL Wins

GraphQL shines when clients have diverse data needs — especially mobile apps with bandwidth constraints or dashboards aggregating data from multiple domains.

tstypescript
// ❌ REST — the mobile app needs 5 requests for one screen
const user = await fetch("/api/users/me");
const orders = await fetch("/api/users/me/orders?limit=3");
const notifications = await fetch("/api/notifications?unread=true");
const recommendations = await fetch("/api/recommendations?limit=5");
const stats = await fetch("/api/users/me/stats");
// 5 round trips, over-fetching on each response
 
// ✅ GraphQL — one request, exact data needed
const DASHBOARD = gql`
  query Dashboard {
    me {
      name
      avatar
    }
    myOrders(limit: 3) {
      id
      total
      status
    }
    notifications(filter: { unread: true }) {
      id
      message
    }
    recommendations(limit: 5) {
      id
      title
      image
    }
    myStats {
      totalOrders
      totalSpent
    }
  }
`;

One request replaces five. The client gets exactly the fields it needs, nothing more. On mobile networks, this matters.

The N+1 Problem in GraphQL

GraphQL's flexible queries create a server-side challenge: resolving nested fields can trigger hundreds of database queries.

tstypescript
// This innocent query triggers 1 + N database calls
// 1 query for users, then N queries for each user's orders
const query = `{
  users(limit: 50) {
    name
    orders {
      id
      total
    }
  }
}`;
tstypescript
// ✅ DataLoader batches and deduplicates database calls
import DataLoader from "dataloader";
 
const orderLoader = new DataLoader(async (userIds: string[]) => {
  // One query for ALL user orders, not one per user
  const orders = await db.orders.findMany({
    where: { userId: { in: userIds } },
  });
 
  // Group by userId and return in the same order as input
  const ordersByUser = new Map<string, Order[]>();
  for (const order of orders) {
    const list = ordersByUser.get(order.userId) ?? [];
    list.push(order);
    ordersByUser.set(order.userId, list);
  }
 
  return userIds.map((id) => ordersByUser.get(id) ?? []);
});
 
// In the resolver
const resolvers = {
  User: {
    orders: (user: User) => orderLoader.load(user.id),
  },
};

DataLoader is essential for any non-trivial GraphQL server. Without it, performance degrades exponentially with query depth.

Authorization Complexity

REST authorization is straightforward — middleware checks before the handler runs. GraphQL authorization must happen at the field level because different fields may have different access rules.

tstypescript
// REST — one auth check per endpoint
app.get("/api/users/:id", requireAuth, requireOwnerOrAdmin, getUser);
 
// GraphQL — auth at the field level
const resolvers = {
  User: {
    email: (user, args, context) => {
      // Only the user themselves or an admin can see email
      if (context.userId !== user.id && !context.isAdmin) return null;
      return user.email;
    },
    salary: (user, args, context) => {
      // Only HR or the user themselves
      if (context.userId !== user.id && !context.roles.includes("hr")) {
        throw new ForbiddenError("Cannot access salary");
      }
      return user.salary;
    },
  },
};

This per-field authorization is more granular but harder to audit. "Who can see what?" becomes a question that requires tracing through resolver code rather than scanning route middleware.

Decision Framework

FactorChoose RESTChoose GraphQL
Client diversity1-2 clients with similar needsMultiple clients with different data needs
Data shapeFlat, resource-orientedDeeply nested, relational
Caching needsHTTP caching importantCustom caching acceptable
Team sizeSmall team, simple APILarger team, dedicated API layer
Real-time needsSSE or WebSocket separatelySubscriptions built-in
API consumersExternal developersInternal clients you control

Key Takeaways

  1. REST is simpler for resource-oriented CRUD with good HTTP caching support
  2. GraphQL reduces over-fetching when clients have diverse, nested data needs
  3. DataLoader is mandatory for any GraphQL server to prevent N+1 query problems
  4. GraphQL shifts complexity to the server — authorization, caching, and performance become harder
  5. Don't choose based on trends — choose based on your client diversity, data shape, and team expertise
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX