system-design

GraphQL

Let the client ask for exactly the data it wants, in one request. It solves over- and under-fetching elegantly and hands you a new set of problems in exchange.

Prerequisites: REST, BFF Time to read: ~22 minutes


The problem it solves

A mobile screen needs a user, their three most recent orders, and each order’s items.

With REST:

GET /users/42              → user
GET /users/42/orders?limit=3   → orders
GET /orders/991/items      → items   } N more calls
GET /orders/992/items
GET /orders/993/items

Five+ round trips on a cellular network. And each response over-fetches — /users/42 returns 40 fields when the screen shows 3. This is REST’s over- and under-fetching problem.

With GraphQL — one request, exactly the data needed:

query {
  user(id: 42) {
    name
    avatar
    recentOrders(first: 3) {
      total
      items { name, price }
    }
  }
}

🚨 The client specifies the shape; the server returns exactly that. No over-fetching (only the requested fields), no under-fetching (the whole graph in one request). This is the core value proposition.


How it works

A single endpoint (POST /graphql) receives queries. Three operation types:

A strongly-typed schema defines everything — this is GraphQL’s backbone:

type User {
  id: ID!
  name: String!
  email: String!
  orders(first: Int): [Order!]!
}

type Order {
  id: ID!
  total: Int!
  status: OrderStatus!
  items: [Item!]!
}

type Query {
  user(id: ID!): User
  orders(status: OrderStatus): [Order!]!
}

🚨 The schema is the contract, and it’s introspectable — clients and tools can query the schema itself, which powers autocomplete, validation, and documentation for free. This is a genuine advantage over REST, where the contract lives in separate OpenAPI files that can drift.

Resolvers fetch each field. This is where the work — and the problems — live:

def resolve_user_orders(user, info, first=None):
    return db.query("SELECT * FROM orders WHERE user_id = ? LIMIT ?", user.id, first)

Each field in the query invokes a resolver. The engine walks the query tree, calling resolvers, and assembles the response in the shape the client asked for.


The problems GraphQL creates

⚖️ GraphQL is not a free win. The costs are real, and a balanced answer names them.

1. 🚨 The N+1 resolver problem

The classic GraphQL trap, and the most important thing to know.

query { orders(first: 100) { customer { name } } }

Naively: 1 query for 100 orders, then 100 separate queries for each order’s customer. 101 database queries for one request.

The fix is DataLoader — batch and cache field resolutions within a request:

# Instead of 100 queries, DataLoader collects the customer IDs and issues ONE:
# SELECT * FROM customers WHERE id IN (1, 2, 3, ..., 100)
customer_loader = DataLoader(lambda ids: batch_load_customers(ids))

🚨 Every serious GraphQL server needs DataLoader (or equivalent batching), and forgetting it is the number one GraphQL performance disaster. Mentioning it unprompted is a strong signal — it shows you’ve actually operated GraphQL, not just read about it.

2. Caching is hard

REST gets HTTP caching for free — GET /users/42 is cacheable at every layer by URL. Caching

GraphQL uses POST to a single endpoint with a different query body every time, so HTTP caching doesn’t work. You need application-level caching (normalized client caches like Apollo, response caching keyed on the query), which is more work and less effective.

🚨 This is a genuine architectural cost, and it’s why GraphQL is a poor fit for public content that a CDN would otherwise cache trivially.

3. Query cost and abuse

🚨 A client can request an arbitrarily expensive query:

query { users { orders { items { product { reviews { author { orders { ... }}}}}}}}

Deeply nested, potentially exponential. A malicious or careless query can take down your database. Defences (all mandatory for a public GraphQL API): query depth limiting, query complexity analysis (assign a cost to each field, reject over a budget), timeout limits, and persisted queries (only allow a pre-approved set of queries, identified by hash).

4. Harder to rate limit

Rate limiting by request count is meaningless when one request can cost 1,000× another. You must rate limit by query cost, which is more complex.

5. Error handling is unusual

GraphQL returns 200 OK even for errors, with errors in an errors array — because a query can partially succeed (some fields resolve, some fail). 🚨 This breaks the usual “check the status code” approach and confuses monitoring and clients that expect HTTP status semantics.

6. Observability is harder

Every request hits one endpoint, so per-endpoint metrics don’t exist. You need field-level and resolver-level tracing to see what’s actually slow.


When GraphQL is the right choice

Good fits:

Poor fits:

🎙️ The balanced position: “GraphQL is a strong fit when we have several clients with genuinely different data needs — it replaces a BFF per client with clients self-serving their shape. But it sacrifices HTTP caching, needs DataLoader to avoid N+1, and requires query-cost limiting on any public endpoint. For simple CRUD or cacheable public content, REST is simpler and I’d stay with it.”


GraphQL federation

For microservices: instead of one monolithic GraphQL server, each service owns part of the graph, and a gateway composes them into one unified schema.

Users service   → owns User type
Orders service  → owns Order type, extends User with `orders`
Reviews service → extends Product with `reviews`
        ↓
   Federation gateway → one unified graph

🚨 This is how large organizations (Netflix, Airbnb) run GraphQL at scale — it lets teams own their part of the graph independently while clients see one API. It’s the GraphQL answer to the BFF/gateway question, and knowing the term (Apollo Federation) is a good currency signal. The cost is a sophisticated gateway and cross-service query planning.


REST vs GraphQL vs gRPC

  REST GraphQL gRPC
Fetching Fixed per endpoint Client-specified Fixed per method
Over/under-fetch Common Solved Common
HTTP caching ✅ Free ❌ Hard
N+1 risk Client-side Server-side (DataLoader) Client-side
Query cost control Simple Complex (needed) Simple
Discoverability OpenAPI ✅ Introspection Proto files
Best for Public, cacheable, CRUD Flexible clients, graph data Internal RPC

⚖️ Trade-offs

  Gain Cost
GraphQL over REST No over/under-fetching; client-driven; introspectable schema No HTTP caching; N+1; query-cost abuse; harder ops
DataLoader Fixes N+1 Extra machinery every resolver path needs
Persisted queries Prevents abuse; enables caching Clients limited to pre-approved queries
Federation Team-owned graph, one API Complex gateway and query planning
Subscriptions Real-time in the same API Stateful connections to manage

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Cause the N+1 disaster, then fix it. Build a GraphQL server with orders { customer { name }} and no batching. Log every database query. Fetch 50 orders and count the queries — you’ll see ~51. Then add DataLoader and watch it drop to 2. This single exercise teaches the most important GraphQL lesson.

2. Compare fetching. Build the same “user + recent orders + items” screen as REST (multiple endpoints) and GraphQL (one query). Count round trips and bytes transferred. The GraphQL advantage is concrete.

3. Attack your own API. Write a deeply-nested query against your GraphQL server and watch it hang or hammer the database. Then add depth limiting and query complexity analysis and watch it get rejected. This is why public GraphQL needs cost control, demonstrated.

4. Explore introspection. Point a GraphQL IDE (GraphiQL, Apollo Studio) at any public GraphQL API and watch autocomplete and inline docs work from the schema alone. Compare to how you’d discover a REST API’s shape.


Check yourself

1. What problem does GraphQL solve that REST struggles with? Over-fetching and under-fetching. In REST, each endpoint returns a fixed shape, so a client either receives more data than it needs (over-fetching — a `/users/42` returning 40 fields when 3 are shown) or must make multiple round trips to assemble what one screen requires (under-fetching — fetch the user, then their orders, then each order's items). GraphQL lets the client specify exactly which fields and relationships it wants in a single request, and the server returns precisely that shape. For clients on constrained networks with diverse data needs — GraphQL's original mobile use case — this eliminates both wasted bandwidth and excess round trips.
2. What is the N+1 resolver problem and how is it solved? When a query fetches a list and then a related field for each item — `orders(first: 100) { customer { name }}` — a naive implementation runs 1 query for the orders and then 1 query per order for its customer: 101 queries for one request. It arises because each field is resolved independently, with no awareness that the sibling items need the same kind of data. The solution is **DataLoader** (or equivalent batching): within a single request, it collects all the IDs a field needs across every item, defers resolution, and issues one batched query (`WHERE id IN (...)`) instead of many, caching results within the request. Every production GraphQL server needs it; omitting it is the most common GraphQL performance failure.
3. Why is caching harder with GraphQL than with REST? REST gets HTTP caching for free: `GET /users/42` is a stable URL, so browsers, CDNs, and reverse proxies can cache the response keyed on that URL, with no application involvement. GraphQL sends a `POST` to a single endpoint (`/graphql`) with a different query body each time, so URL-based HTTP caching is useless — every request looks the same to a cache, and the actual variation is in the body. You must move caching into the application: normalized client-side caches (Apollo Client), server-side response caches keyed on the query and variables, or persisted-query caching. It's more work, less effective, and it's the main reason GraphQL is a poor fit for public content that a CDN would otherwise cache trivially.
4. Why does a public GraphQL API need query cost controls? Because a client can request an arbitrarily expensive query — deeply nested relationships that fan out combinatorially — and one such query can overwhelm the database, effectively a denial-of-service vector that doesn't exist in REST (where each endpoint has a bounded, fixed cost). Standard request rate limiting doesn't help, since one request can cost a thousand times another. So public GraphQL requires: query depth limiting (reject queries nested beyond N levels), query complexity analysis (assign a cost to each field and reject queries over a budget), execution timeouts, and often persisted queries (only allow a pre-approved, hashed set of queries). Rate limiting must be by query *cost*, not request count.
5. What is GraphQL federation and when would you use it? Federation splits a single GraphQL API across multiple services, each owning part of the schema, with a gateway composing them into one unified graph that clients query. The users service owns the `User` type, the orders service extends `User` with an `orders` field and owns `Order`, the reviews service extends `Product` with `reviews` — and the gateway plans and executes queries that span them. You'd use it in a microservices organization where you want the benefits of GraphQL (clients self-serving flexible shapes over a unified graph) while letting each team own and deploy its part independently, rather than a monolithic GraphQL server that becomes a bottleneck. It's the GraphQL answer to the BFF/gateway question, and it's how Netflix and Airbnb run GraphQL at scale. The cost is a sophisticated gateway with cross-service query planning.

Further reading