System Design APIs
GraphQL
Imagine opening Instagram and waiting for one feed card: post text, author avatar, like count, and the top three comments. With classic (one URL per resource, fetched with HTTP verbs), that card can take a waterfall of requests, and each response may ship fields the phone never shows.
was born for that pain. The client describes one shaped tree of fields and gets it back in a single round trip. The trade-off is that the server now does the fan-out work, so you must watch query cost, (one extra database call per child row), and caching that no longer keys cleanly on a URL.
Feed card journey
Client writes a query
The card component lists exactly the fields it renders, and nothing more.
Unknown or disallowed fields are rejected before any resolver does expensive work.
Each requested field loads from a service or database through its own resolver function.
Batches nested author lookups into one query so N+1 fan-out does not explode.
Try it: GraphQL resolver fan-out versus batching
Execute the same nested feed query under naive per-field fetching and under request-scoped batching. Count backend calls instead of guessing from the GraphQL shape alone.
- Queries executed
- 0
- Backend calls (cumulative)
- 0
- Posts per query
- 10
Run a feed query that loads ten posts and each post's author. Naive resolvers issue one backend call per post; batched loading collapses author lookups.
What actually happens in one GraphQL request
The feed card journey above compresses four steps; here they are slowed down. The client posts (usually) to a single endpoint with a query document. That document is not "give me the user resource". It is "give me name, avatar, and the first three comments with author names." The server checks the document against its (the typed contract of every field a client may ask for), runs a for each requested field, and returns a response tree that matches the query tree exactly.
That one mechanism fixes two REST problems at once. is when one response is not enough, so the client chains follow-up calls and pays a round trip for each. is when a fixed response carries fields the screen never shows, so bandwidth, parse time, and memory are wasted. Nesting the fields you want in one query removes the extra round trips; listing only those fields removes the wasted bytes. The server still has to do the work, though: nested resolvers can fan out into many database calls unless you batch them with (a per-request batching and caching helper) or with joins at the service layer.
Two comparisons frame the rest of the page. Compared with , you gain shape flexibility and lose easy URL-based HTTP caching, because queries often use POST to one URL. Read queries can also use GET when supported, which enables HTTP caching with an appropriate cache key. Compared with (typed binary RPC between services you control), you stay JSON and human-friendly at the product edge, but you are not optimizing for internal throughput.
Why GraphQL was born: one feed card with REST
The prose above names the two problems; this section shows the numbers that made them hurt. In 2012, a social feed app's mobile client was slow and data-hungry because forced too many requests for too much data. To render one single post card, here is what REST required, and what the same card looks like as a single query.
❌ REST: 7 sequential/parallel requests
// Rendering ONE post card needs:
GET /api/feed → post IDs
GET /api/posts/456 → post content
GET /api/users/123 → author name + avatar
GET /api/posts/456/likes → like count
GET /api/posts/456/comments?limit=3 → top 3 comments
GET /api/users/789 → comment author 1
GET /api/users/101 → comment author 2
// 7 round trips! On 3G mobile = 7 × 200ms = 1.4 seconds
// AND each returns ALL fields (over-fetching)✅ GraphQL: 1 request, exact data
query NewsFeed {
feed(first: 10) {
posts {
id
text
createdAt
author { name, avatarUrl }
likeCount
comments(first: 3) {
text
author { name }
}
}
}
}
// 1 round trip! On 3G = 200ms
// Returns ONLY the fields listed above: the chatty waterfall
The seven requests above are under-fetching in action: one endpoint does not give you enough, so you chain more, and on a high-RTT (round-trip time) mobile network each link in the chain is paid in full before the UI can render.
Latency ≈ (1 + N + N) × RTT before the UI is complete
// You need: user name + their posts + follower count
// REST gives you:
GET /users/123
→ { id, name, email, bio, phone, address, ... }
// ✅ Got name, but no posts or followers
GET /users/123/posts
→ [{ id, title, body, tags, ... }, ...]
// ✅ Got posts, but still no followers
GET /users/123/followers/count
→ { count: 1420 }
// ✅ Finally got everything!
// 3 round trips. 3× latency on slow networks.The cost: on a 100ms RTT mobile connection, three sequential requests mean 300ms minimum before the UI can render anything.
: wasted bytes
The other half of the pain is that each of those fixed REST responses returns every field of the resource. A comment card only wanted the author's name, but got the entire user object.
Same endpoint shape for every client → over-fetch by default
// You only need: user's name for a comment card
GET /users/123
→ {
id: 123,
name: "Alice", // ✅ needed
email: "a@b.com", // ❌ wasted
phone: "+1-555-0123", // ❌ wasted
address: { ... }, // ❌ wasted (500 bytes)
preferences: { ... }, // ❌ wasted (1KB)
paymentMethods: [ ... ], // ❌ wasted (2KB)
loginHistory: [ ... ], // ❌ wasted (5KB)
}
// You needed 20 bytes, got 8KB. 400× waste.
// ×1000 users on news feed = 8MB wasted data.The cost: wasted bandwidth, slower parsing, and higher memory on the device, especially painful on metered 3G/4G connections.
How GraphQL solves both problems at once
1. Exact fields, no over-fetching
The client lists only what the component uses, and the server returns nothing more.
2. One round trip, no under-fetching
Related data is nested in a single query, so there is no waterfall of chained calls.
3. Shape matches the UI
Query shape equals response shape equals component tree, so no mapping layer is needed.
Complete example: a user profile page
Putting both halves together on a second screen, here is the full REST versus GraphQL comparison for a profile page.
REST: 4 requests, ~12KB response
// Request 1
GET /users/123 → 8KB (all fields)
// Request 2
GET /users/123/posts?limit=5 → 3KB
// Request 3
GET /users/123/followers/count → 50B
// Request 4
GET /users/123/following/count → 50B
// Total: 4 RTTs, ~12KB transferredGraphQL: 1 request, ~800B response
query UserProfile {
user(id: "123") {
name
avatarUrl
bio
posts(first: 5) {
title
createdAt
}
followerCount
followingCount
}
}
// Total: 1 RTT, ~800B transferred93% less data transferred and 75% fewer round trips. On mobile, that is the difference between instant and a spinner.
How to choose GraphQL
Those numbers explain the appeal, but they do not by themselves justify for your system. It is as much an organizational choice as a technical one, and three questions decide it.
1. Do clients need different shapes from the same graph? The iOS team needs 5 fields, Android needs 8, web needs 12. With you either build custom endpoints per client (endpoint explosion, often as a BFF, a backend-for-frontend layer per screen) or return everything and over-fetch. If that divergence keeps exploding BFFs or bloating REST DTOs (data transfer objects), GraphQL is a strong fit: each team queries exactly what it needs from the same endpoint.
2. Are frontend requirements still changing weekly? With REST, a redesigned card means the frontend files a ticket, the backend adds an endpoint, and everyone waits for a deploy. With GraphQL the frontend changes the query and ships immediately. That works because the backend owns the (what is possible) while the frontend owns the query (what is needed), so the schema is the contract and both teams work in parallel without meetings about which fields to include.
3. Are the requirements already fixed and simple? A known set of screens over a handful of CRUD resources rarely justifies GraphQL overhead; REST with a few well-designed endpoints, or a thin BFF, is usually enough. Saying "we'll use GraphQL to be modern" without naming under-fetching, over-fetching, or client divergence is a weak signal.
Simple rule of thumb
Name the pain first, then the protocol. Use GraphQL when multiple clients and nested relationships make fixed REST shapes painful, and when the problem explicitly values query flexibility or rapid frontend iteration without backend redeploys. The three-way REST, , and GraphQL comparison further down covers when the other two win.
Signals to look for
Strong signals for GraphQL
- Multiple client types (web, iOS, Android) that each need a different slice of data.
- Many teams or separate backend and frontend orgs that want to ship independently.
- Complex nested data such as social graphs or e-commerce catalogs with deep relationships.
- Frequent UI changes that should ship without waiting on a backend deploy.
- Mobile-first products on slow networks, where every wasted byte delays the first render.
- Microservices that need one unified API through (covered below).
- News feed or aggregation screens that stitch data from several backend services.
Strong signals against GraphQL
- Simple CRUD with 5–10 resources, where a few REST endpoints are easier to run.
- Tiny team or MVP, where the operational overhead is not worth it yet.
- Heavy file uploads, because binary data is awkward in the GraphQL spec.
- Cache-heavy public API, where HTTP GET caching is far simpler with REST.
- Single client type, so there are no fetching shape differences to serve.
- Public API with unknown clients, which is harder to rate limit fairly.
- Real-time high-volume streaming between services, where gRPC is usually the better fit.
- Team lacks GraphQL operational expertise for cost limits, caching, and field-level authorization.
Queries, mutations, and subscriptions
The schema defines available fields and types; a query selects from that contract. A client cannot request a new business capability merely by naming a field that the backend has not implemented. Mutations identify operations that may change state, but GraphQL itself does not make a multi-field mutation into a database transaction. The resolver’s storage logic must provide any required atomicity.
Once you have decided GraphQL fits, the next thing to learn is its three operation types. Reads, writes, and push-style updates each use a distinct operation, and all three are typed by the same .
Query (Read)
query {
user(id: "123") {
name
email
posts(first: 5) {
title
likes
}
}
}One read request replaces three or more REST calls for the same screen.
Mutation (Write)
mutation {
createPost(input: {
title: "Hello World"
body: "Content here"
}) {
id
createdAt
}
}The write path, like POST, PUT, or DELETE in REST, and it returns the fields you ask for.
Subscription (Real-time)
subscription {
newMessage(chatId: "abc") {
id
text
sender { name }
sentAt
}
}Live updates pushed over a WebSocket connection whenever the selected event fires.
Resolvers, server-side fan-out, and the N+1 problem
Every field in those operations is filled by a (the function that loads one field's data), and that is where the server-side cost hides. The client saved its round trips; the gateway now makes them instead.
📰 Real pattern: GraphQL as the news feed aggregation layer
Not always, but for news feed type applications, is often the single aggregation endpoint that stitches data from multiple backend services:
# News feed query: one request aggregates from 4 services
query NewsFeed($userId: ID!, $limit: Int!) {
feed(userId: $userId, first: $limit) {
posts {
id
text # → Post Service
author {
name, avatarUrl # → User Service
}
likeCount # → Like Service
topComments(first: 3) {
text # → Comment Service
author { name } # → User Service (batched)
}
}
pageInfo { hasNextPage, endCursor }
}
}Why this works: the gateway orchestrates the fan-out to backend services server-side, over a fast internal network, instead of the mobile client making four or more requests over slow cellular. batches the user lookups across comments, and the client gets one shaped response matching its UI.
The problem and DataLoader
That server-side fan-out is also the biggest performance pitfall in GraphQL. Nested fields trigger one database query per child row unless something batches them: without mitigation, a query for 100 users with their posts makes 1 query for users plus 100 queries for posts, 101 database queries in total.
Without DataLoader (N+1)
SELECT * FROM users LIMIT 100; -- 1 query
SELECT * FROM posts WHERE user_id=1; -- query 2
SELECT * FROM posts WHERE user_id=2; -- query 3
... (98 more)
-- Total: 101 queries!With DataLoader (batching)
SELECT * FROM users LIMIT 100; -- 1 query
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ..., 100); -- 1 query
-- Total: 2 queries!The DataLoader pattern
- Collect every ID requested during one event-loop tick instead of querying immediately.
- Batch those IDs into a single database query using
WHERE id IN (...). - Cache results for the lifetime of the request, so a repeated author loads once.
- Keep that cache per request, not global, so stale data never leaks between users.
A large social app invented DataLoader for this exact problem.
Worked example: N+1 cost on a feed
Batching collects author identifiers requested during one execution window and loads them together. The batch result must map back to the original identifiers, including duplicates and missing records, so each resolver receives the correct author. Scope a loader to the request or authorization context: a global memoized loader can return data fetched under another user’s permissions. Batching reduces query count; indexes, maximum list sizes, and a bounded database connection pool still determine the cost of each batch.
For a feed of 50 items resolving their authors, an unbatched implementation may issue one feed query plus 50 author queries. If those calls execute sequentially and each waits 5 ms, network waiting alone is about 255 ms. Concurrent calls overlap that time but still create 51 queries and can exhaust the connection pool. Batching the authors can reduce this to two queries; the real latency then depends on the database work and scheduling, not only the round-trip count.
GraphQL execution cost
Client query
GraphQL gateway
Parse + validate
Plan fields + auth
User service
Feed service
Media service
Batch loaders
Assemble partial response
Payload and bandwidth budget
Header overhead
Assume each request carries 2 KB of headers and the service receives 50,000 requests per second. Multiplying bytes per request by requests per second gives 100 MB/s of request headers alone, using decimal units. This excludes response bodies and lower-level overhead. Reducing a cookie helps repeatedly because the browser may attach it to every matching request.
Field trimming
8KB response trimmed to 800B saves 7.2KB. Across 1B reads, that is about 7.2TB less transfer.
Compression
100KB JSON compressed to 20KB saves 80KB. Across 10M responses, that is about 800GB less egress.
Fan-out
One 600B event to 10K subscribers becomes 6MB of outbound payload, before protocol and encryption overhead.
Keeping query cost bounded
Depth alone is insufficient. A query only three levels deep can request 10,000 posts with 1,000 comments each, creating ten million child results. Combine per-field cardinality limits with an estimated total cost and a runtime budget. Charge expensive queries according to their work rather than counting every HTTP request as equal. Rejecting excessive work before execution protects the database; cancellation and concurrency limits handle estimates that prove too optimistic.
Batching fixes accidental fan-out; it does nothing against a query that is deliberately or clumsily enormous. Because clients write their own queries, a malicious or careless one can fan out exponentially, so the server must guard cost before execution with depth limits, complexity scores, timeouts, rate limits, and (pre-approved query shapes, covered in the next section).
Malicious deeply nested query (denial of service):
query {
user {
friends {
friends {
friends {
posts {
comments {
author {
friends { ... }
}
}
}
}
}
}
}
}Five guards, applied before a single resolver runs
Max Depth Limit
Complexity Analysis
Query Timeout
Rate Limiting
Persisted Queries
Failure timeline: unbounded nested query
Here is what happens when none of those guards exist, minute by minute.
T0 Client posts friends {{ friends {{ friends {{ ... }}}} depth 12
T1 Resolvers fan out; connection pool fills; p99 for all tenants climbs
T2 No depth/complexity limit → one query saturates the graph gateway
T3 Sibling REST/gRPC traffic shares the same DB → cascading timeouts
T4 Enforce max depth + cost score + timeout; reject before execution
Flexibility without a cost model turns the public graph into a self-DoS (denial of service) endpoint.
Correctness invariant
What must never become false
The cost guards above protect availability; this rule protects correctness. Every field a client receives was authorized for that caller, and every executed query stays inside a declared cost budget (depth, complexity, timeout, quota). Batching and caching may collapse lookups, but they must not leak another tenant's data or skip field-level authorization.
Persisted queries and the caching problem
Distinguish a trusted query allowlist from automatic persisted-query caching. An allowlist permits only operations registered by your release process. Automatic persistence may let a client submit a new document when its hash is unknown, so it saves bandwidth without automatically restricting what clients can execute. For either approach, variables still influence cost and authorization. A cached response key must include relevant variables and identity or tenant context.
The last guard in the accordion above, , also solves GraphQL's biggest caching weakness, so it earns a closer look. The idea is to ship the allowed query shapes ahead of time so production only executes known-safe plans.
Build Time: Extract & Hash
Query: { user(id: $id) { name, avatar } }
Hash: "abc123def456"Runtime: Send Only the Hash
POST /graphql
{ "id": "abc123def456", "variables": { "id": "42" } }- Smaller payloads, because the client sends a short hash instead of the full query string.
- No arbitrary queries reach production, since only allow-listed shapes are ever accepted.
- The server can pre-optimize the execution plan for each known query shape.
- A CDN can cache
GET /graphql?id=abc123, because the URL now identifies the query.
Common in: large social app, commerce service, travel marketplace.
Why caching is harder than REST in the first place
That last benefit matters because ordinary GraphQL fights the HTTP caches the web is built on, which are tuned for URL-addressable GET resources.
caching (easy)
GET /users/123 → the cache key is the URL. CDN, browser, and proxy caches all work out of the box with Cache-Control: max-age=300.
GraphQL caching (hard)
POST /graphql → every query hits the same URL, a CDN cannot cache POST by default, and different queries return different shapes.
GraphQL Caching Solutions
- Persisted queries served as GET /graphql?id=abc, which a CDN can cache by URL.
- Normalized client cache keyed by type plus ID, for example User:123 → { name, email }.
- Response cache at the gateway, keyed on a hash of the query plus its variables.
- @cacheControl directive per field: name @cacheControl(maxAge: 3600), feed @cacheControl(maxAge: 60).
Scaling the graph: federation, pagination, errors, and performance
With cost and caching under control, the remaining questions are the ones that appear once a graph spans many teams and billions of requests: who owns which part of the schema, how big lists page, how errors are reported, and what the optimization stack looks like.
for microservices
Federation lets multiple subgraphs (one per team or service) compose into one graph while each team owns its slice independently.
The gateway composes them into one unified schema. The client sees ONE API, and teams deploy independently.
Schema stitching vs federation: stitching merges schemas at runtime (the older approach). In federation, services declare relationships via the @key directive and the gateway builds a query plan (the modern approach).
Common in: large streaming service (100+ subgraphs), travel search service, vehicle platform.
Cursor pagination and query batching
Whether the graph is federated or not, huge lists need stable cursors and repeated small queries need batching to stay performant and predictable.
Cursor Pagination
query {
user(id: "123") {
posts(first: 10, after: "cursor_abc") {
edges {
cursor
node { id, title }
}
pageInfo { hasNextPage, endCursor }
}
}
}Query Batching (reduce HTTP round trips)
POST /graphql
[
{ "query": "{ user(id: 1) { name } }" },
{ "query": "{ user(id: 2) { name } }" },
{ "query": "{ product(id: 99) { price } }" }
]
// Server processes all 3 in one HTTP request.Structured error handling
A GraphQL response can contain useful data and errors at the same time. If an optional avatar lookup fails, the product may still show the profile text. A failure in a non-null field propagates to the nearest nullable parent, which can remove more of the response than the original field. Choose nullability according to that failure behavior and have clients inspect the GraphQL error payload, rather than equating an HTTP success code with a completely successful query.
GraphQL execution can return partial data together with field errors, often using a successful HTTP status. Invalid documents, authentication failures, and requests that cannot execute can use HTTP error statuses; exact behavior also depends on the negotiated response media type. Clients should inspect both the HTTP status and the GraphQL data/errors payload instead of assuming every failure is represented by HTTP 200. See GraphQL HTTP guidance for the precise contract.
{
"data": { "user": null },
"errors": [{
"message": "User not found",
"extensions": { "code": "NOT_FOUND" }
}]
}Partial success is possible: data and errors arrive in the same response, unlike REST where a 404 means no data.
Performance at billion scale
The optimization stack, in order:
- Persisted queries, so the server skips parsing and validating query text.
- DataLoader batching with per-request caching, so nested lookups collapse into few queries.
- @defer and @stream, which send fast fields first and slow ones incrementally.
- Response caching at the gateway for repeated query-plus-variables combinations.
- A normalized client cache, so the app reuses objects it already fetched.
@defer example (progressive loading):
query {
user { name }
... @defer { analytics { ... } }
}GraphQL vs REST vs gRPC
Everything above is the GraphQL side of the story. This table puts it beside and so you can see the trade: flexibility versus caching versus typed RPC, decided by the clients you control and the operational tooling you can run.
| Aspect | GraphQL | REST | gRPC |
|---|---|---|---|
| Data fetching | Client specifies fields | Server defines response | Proto-defined messages |
| Over-fetching | None (exact fields) | Common (full objects) | None (typed messages) |
| HTTP caching | Hard (POST to single URL) | Easy (GET + URL = cache key) | Hard (binary, HTTP/2) |
| Best for | Multi-client, complex graphs | Public APIs, simple CRUD | Internal microservices |
| Real-world | large social app, developer API, commerce service | payment API, messaging API, every API | large platform, large streaming service, large mobility service |
How to choose REST, gRPC, or GraphQL
Choose the API style by client type, payload shape, caching needs, and operational maturity.
REST
Best default for browser and public HTTP APIs: cacheable resources, simple debugging, visible URLs, and broad infrastructure support.
gRPC
Best for controlled service-to-service calls: typed contracts, compact binary payloads, deadlines, streaming, and high request volume.
GraphQL
Best when many clients need different nested shapes: fewer round trips, less over-fetching, but harder caching and stricter query governance.
When debugging feels stuck
Split latency into client wait, gateway time, upstream fan-out, serialization, payload size, and cache behavior before changing protocols.
Simple rule of thumb
Start with REST for public clients, gRPC for busy internal RPC, and GraphQL only when nested shape flexibility is worth the caching and governance cost.
Wrap-up
You have seen the pain GraphQL removes, the cost it moves onto the server, and the controls that keep that cost bounded. Use this section to check the ideas stuck, then follow the references for the primary sources.
How to explain this in a design review
GraphQL is impressive only when you also explain how you control query cost, cache results, and keep field-level security correct. Lead with the client divergence that justifies it, then walk the cost guards and caching escapes above, and authorize at the or field boundary rather than only at the top-level query. Keep these constraints in view while you do:
- One client request can still fan out to many slow downstream services.
- POST-to-one-URL makes CDN caching harder unless persisted GET queries are used.
- Mobile clients benefit from smaller payloads but can be hurt by very large nested responses.
- Partial errors mean clients must handle
dataanderrorstogether in every response. - Debug slow queries with resolver timings and query plans, not only HTTP latency.
Exercises
- Cost points. Merchants get 1000 cost points per second. Assign costs so a simple product query is ≤5 and a nested catalog browse is ≤200. What happens at the limit: an error or partial data?
- CDN-cached feed. You need CDN caching for a mobile home feed. Design as GET. What do you key on, and how do you invalidate after a follow?
- Field-level authorization. A field resolver returns email. Who may see it: self, admin, or friend? Write down where the authz check lives, and a counterexample if you only gate the root query.
Topic-specific references
- GraphQL Specification : schema, execution, and error model.
- facebook/dataloader : per-tick batching to collapse N+1 lookups.
- Apollo: Automatic Persisted Queries : GET-friendly hashes for CDN caching and allow-lists.
What to remember
shines with multiple clients and complex nested relationships. Always cover (the fix), depth and complexity limiting, persisted queries, and when microservices need one graph. Know why caching is harder than REST, and how normalized client caches plus persisted GETs help.
Practice and further reading
Use these exercises to connect the lesson to a design decision. State your assumptions, trace what happens to one request, and explain the rule that must remain true even during a failure. That rule is the design's correctness invariant.
Focus for this lesson: Control graph-shaped query flexibility with schema governance, batching, authorization, cost limits, observability, and compatible evolution.
Explain the decision
How do you prevent GraphQL N+1 database queries?
Read the reasoning
Batch and cache resolver loads per request, expose query plans and resolver spans, and shape APIs so list fields can fetch related data in sets.
Apply it to a production scenario
A nested query is valid but saturates the database. What controls help?
Read one approach
Use persisted-query allowlists, depth/complexity budgets, pagination caps, per-field cost weights, timeouts, and tenant quotas.
Work through the numbers
A query returns 100 orders and triggers 1 customer plus 3 item lookups per order. Compare naive and batched calls.
Read the worked calculation
Naive fan-out is about 401 calls including the root; batching can reduce it to roughly four set-based calls, depending on ownership.
Reason through a failure
What must field-level authorization prevent?
Read the recovery reasoning
Adding an authorized parent field must never expose a child field the caller lacks permission to read, including through aliases and fragments.