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
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.
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.
⚖️ GraphQL is not a free win. The costs are real, and a balanced answer names them.
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.
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.
🚨 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).
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.
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.
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.
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.”
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 | 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 |
| 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 |
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.