GraphQL: When REST Isn't Enough

📘 Chapter 11: API Design ⏱️ 9 min read 🏗️ Lesson 046

REST works beautifully when your clients have simple, predictable data needs. But what happens when a mobile app needs a tiny slice of data, a web dashboard needs deeply nested objects, and an admin panel needs everything? You end up with either over-fetching (sending too much data) or under-fetching (requiring multiple round trips). GraphQL solves this by letting clients ask for exactly what they need.

The Problem GraphQL Solves

Over-fetching & Under-fetching

Over-fetching: GET /users/42 returns 30 fields when you only need name and avatar. Wasted bandwidth, especially on mobile.

Under-fetching: To show a user's profile with their latest orders and each order's products, you need:

  1. GET /users/42 → get user
  2. GET /users/42/orders → get orders
  3. GET /orders/7/products, GET /orders/8/products... → get products per order

That's 4+ requests and a waterfall of latency. GraphQL does it in one.

REST (Multiple Requests) vs GraphQL (Single Request) REST: 3 Round Trips Client GET /users/42 {user + 30 unused fields} GET /users/42/orders [orders array] GET /orders/7/products [products array] ⚠️ 3 requests, waterfall latency ⚠️ Over-fetched user fields ⚠️ ~2.4 KB transferred GraphQL: 1 Request Client POST /graphql { user(id: 42) { name avatar orders { id, total products { name } } } ✓ 1 request, no waterfall ✓ Only requested fields returned ✓ ~0.8 KB transferred
Figure 1: REST requires multiple requests and returns excess data. GraphQL fetches exactly what's needed in a single request.

Schema Definition

Types, Queries, Mutations, Subscriptions

GraphQL APIs are defined by a schema — a strongly typed contract between client and server:

type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]!
}

type Order {
  id: ID!
  total: Float!
  status: OrderStatus!
  products: [Product!]!
}

type Query {
  user(id: ID!): User
  users(limit: Int, after: String): UserConnection
}

type Mutation {
  createOrder(input: CreateOrderInput!): Order!
  updateUser(id: ID!, input: UpdateUserInput!): User!
}

type Subscription {
  orderStatusChanged(orderId: ID!): Order!
}

Queries read data, Mutations write data, and Subscriptions push real-time updates over WebSockets.

The N+1 Problem

Naive Resolvers Are Deadly

Consider fetching 50 users with their orders. A naive implementation:

  1. 1 query to get 50 users
  2. 50 queries to get each user's orders (one per user)

That's 51 database queries for one GraphQL request! The solution: DataLoader.

// DataLoader batches individual loads into one query
const orderLoader = new DataLoader(async (userIds) => {
  // One query: SELECT * FROM orders WHERE user_id IN (...)
  const orders = await db.orders.findByUserIds(userIds);
  // Return orders grouped by user_id in the same order as input
  return userIds.map(id => orders.filter(o => o.userId === id));
});

DataLoader collects all .load(userId) calls within a single tick, batches them into one DB query, then distributes results back. 51 queries → 2 queries.

When to Use What

Dimension REST GraphQL
Data fetching Fixed endpoints, fixed shapes Flexible queries, client-defined shapes
Caching HTTP caching works perfectly (GET + URL = cache key) Harder — all requests are POST to /graphql
File uploads Native multipart support Requires workarounds (multipart spec extension)
Multiple clients Often need separate BFF endpoints Each client queries exactly what it needs
Learning curve Low — HTTP basics suffice Medium — schema, resolvers, DataLoader
Tooling Mature — curl, Postman, any HTTP client Great — GraphiQL, codegen, type safety
Best for Simple CRUD, public APIs, microservices Complex UIs, mobile apps, diverse clients

Security Concerns

GraphQL Gives Clients Too Much Power?

Without guards, a malicious client can craft deeply nested or absurdly expensive queries:

# Malicious query — exponential nesting
{ user { friends { friends { friends { friends { ... } } } } } }

Defenses:

  • Query depth limiting: Reject queries deeper than N levels
  • Complexity analysis: Assign cost to each field, reject queries exceeding a budget
  • Persisted queries: Clients can only execute pre-approved query hashes — no arbitrary queries in production
  • Timeout & rate limiting: Kill slow resolvers, limit requests per minute

🌍 Facebook: Where GraphQL Was Born

Facebook created GraphQL in 2012 for their mobile app rewrite. The problem: their REST API returned massive payloads that mobile devices on 2G/3G couldn't handle efficiently. Results:

  • 50% reduction in data transferred to mobile apps
  • Eliminated hundreds of one-off REST endpoints
  • Mobile and web teams could iterate independently — no backend changes needed for new UI features

🌍 Shopify's Storefront API

Shopify serves thousands of unique storefronts — each needs different data shapes. Their GraphQL Storefront API lets each merchant's custom frontend fetch exactly the product data, collections, and checkout info it needs without Shopify maintaining thousands of endpoint variations.

Interactive: Write a GraphQL Query

🎮 Query Builder

Write a GraphQL query to fetch the requested data. The response shape will match your query!