system-design · intermediate
REST vs. GraphQL — Fixed Resources vs. Client-Driven Queries
The Central Question
Consider a mobile application running on the APILab platform (apilab.com):
- To render a user profile screen, the mobile app needs the user's
name,avatarUrl, and the total count of unread notifications. - Using a traditional REST API, calling
GET /v1/users/4401returns a heavy 15 KB JSON payload containing 45 un-needed fields (full address history, payment tokens, internal flags). This is Over-Fetching. - Simultaneously, fetching unread notification counts requires a second HTTP call (
GET /v1/notifications/unread), while fetching recent orders requires a third HTTP call (GET /v1/orders). This is Under-Fetching (Waterfalling).
Over cellular networks with high round-trip latency, making 3 sequential HTTP round-trips to render a single screen causes slow loading spinners and burns mobile battery.
GraphQL is an open-source query language and server-side runtime that allows clients to declare the exact data fields they require in a single HTTP request document, which the backend fulfills cleanly.
This lesson answers one central question: How do REST and GraphQL differ in client-server data fetching (over-fetching vs under-fetching), and how do engineers resolve GraphQL's primary operational hazard—the N+1 Database Query Problem—using DataLoader batching?
Over-Fetching vs. Under-Fetching (The REST Dilemma)
The architectural push for GraphQL stems from two inherent limitations of fixed-resource REST endpoints:
flowchart TD
subgraph REST Dilemma
OverFetch["1. Over-Fetching<br/>Client queries GET /v1/users/4401.<br/>Server returns 15 KB payload with 45 fields.<br/>Client uses only 2 fields! (Wasted Bandwidth)"]
UnderFetch["2. Under-Fetching (Waterfalling)<br/>Client needs User + Orders + Notifications.<br/>Forces 3 sequential network round-trips!<br/>Latency = T1 + T2 + T3"]
end
subgraph GraphQL Solution
SingleQuery["Client sends 1 GraphQL Query Document to /graphql.<br/>Declares exact fields: user { name, avatar }, unreadCount.<br/>Server returns 1 trimmed JSON response in 1 round-trip!"]
end
OverFetch --> SingleQuery
UnderFetch --> SingleQuery
Figure 1: Conceptual comparison of REST over-fetching / under-fetching vs GraphQL single-query execution.
1. Over-Fetching
The server defines the fixed shape of every endpoint response. Even if a mobile widget needs only a user's `name`, the server serializes and transmits the entire `User` database object over the network.2. Under-Fetching & Network Waterfalling
A single screen requires data from multiple distinct domain entities. In REST, the client must issue multiple HTTP requests to different endpoints (`/users`, `/orders`, `/notifications`). The client cannot render the screen until all HTTP round-trips complete sequentially.The GraphQL Mental Model: Single Endpoint & Schema SDL
Unlike REST, which exposes dozens of distinct URIs (/users, /orders, /products), GraphQL exposes a single HTTP endpoint (typically POST /graphql).
flowchart LR
subgraph Client App
Doc["GraphQL Query Document:<br/>query {<br/> user(id: '4401') {<br/> name<br/> avatarUrl<br/> unreadCount<br/> }<br/>}"]
end
subgraph GraphQL Engine (POST /graphql)
Schema[Schema SDL Definition] --> Parser[Query AST Parser & Validator]
Parser --> Resolvers[Field Resolvers]
Resolvers --> DB[(PostgreSQL / Redis)]
end
Doc -->|POST /graphql| Schema
Resolvers -->|Exact JSON Response| Doc
Figure 2: The execution flow of a GraphQL query document against a single /graphql endpoint.
1. Schema Definition Language (SDL)
The server defines a strongly typed schema establishing available types, fields, and relationships:type User {
id: ID!
name: String!
avatarUrl: String
orders(limit: Int): [Order!]!
unreadNotificationCount: Int!
}
type Order {
id: ID!
totalPrice: Float!
status: String!
}
type Query {
user(id: ID!): User
}
2. Client-Driven Selection Set
The client sends an HTTP POST request containing a GraphQL query document specifying its exact field requirements:# Client Request sent to POST /graphql
query GetUserProfileScreen {
user(id: "usr_4401") {
name
avatarUrl
unreadNotificationCount
}
}
3. Server JSON Response
The GraphQL server executes field **Resolvers** and returns a JSON payload matching the *exact structural shape* of the query document:{
"data": {
"user": {
"name": "Alice Smith",
"avatarUrl": "https://cdn.apilab.com/avatars/4401.jpg",
"unreadNotificationCount": 3
}
}
}
The N+1 Database Query Problem
While GraphQL delivers unprecedented client flexibility, it introduces a severe backend performance hazard known as the N+1 Database Query Problem.
How the N+1 Bug Occurs
Consider fetching a list of 100 recent orders, including the author name for each order (`orders { id, author { name } }`).In a naive GraphQL implementation:
- The root resolver executes 1 database query to fetch 100 order records (
SELECT FROM orders LIMIT 100). - For each of the 100 orders, the GraphQL engine invokes the
authorfield resolver individually. - The server executes 100 individual database queries (
SELECT FROM users WHERE id = order.author_id).
$$\text{Total DB Queries} = 1 + N = 1 + 100 = 101 \text{ Database Queries!}$$
sequenceDiagram
autonumber
participant Engine as GraphQL Engine
participant DB as PostgreSQL DB
Engine->>DB: 1. SELECT * FROM orders LIMIT 100 (Returns 100 Rows)
DB-->>Engine: 100 Order Records
loop 100 Times (The N+1 Disaster!)
Engine->>DB: 2. SELECT * FROM users WHERE id = 1
Engine->>DB: 3. SELECT * FROM users WHERE id = 2
Engine->>DB: 4. SELECT * FROM users WHERE id = ...
Engine->>DB: 101. SELECT * FROM users WHERE id = 100
end
Figure 3: Sequence diagram detailing the N+1 query explosion during un-batched GraphQL resolution.
The Solution: DataLoader Batching & Caching
To solve the N+1 problem, production GraphQL servers use DataLoader batching.
DataLoader is a utility that collects individual load requests during a single event-loop tick, deduplicates keys, and executes a single batched SQL query using WHERE id IN (...):
sequenceDiagram
autonumber
participant Engine as GraphQL Engine
participant DL as DataLoader Queue
participant DB as PostgreSQL DB
Engine->>DB: 1. SELECT * FROM orders LIMIT 100
DB-->>Engine: 100 Order Records
Note over Engine,DL: During 1 Event Loop Tick:<br/>100 author(id) calls queued into DataLoader!
Engine->>DL: Queue Keys: [1, 2, 3, ... 100]
DL->>DB: 2. ONLY 1 BATCH QUERY: SELECT * FROM users WHERE id IN (1, 2, 3, ... 100)
DB-->>DL: Returns 100 User Rows
DL-->>Engine: Maps User Rows back to 100 Resolvers!
Figure 4: DataLoader collapsing 100 individual user lookups into 1 batched SQL IN clause.
Using DataLoader, the query count drops from $1 + N$ ($101$ queries) down to 2 queries:
$$\text{Total DB Queries with DataLoader} = 1 + 1 = 2 \text{ Database Queries!}$$
Architectural Comparison Matrix
| Operational Feature | REST Architecture | GraphQL Architecture |
|---|---|---|
| Endpoint Topology | Multiple URIs (/users, /orders). | Single Endpoint (POST /graphql). |
| Data Fetching Control | Server-Defined fixed JSON shape. | Client-Defined flexible query document. |
| Over-Fetching | Common (Returns un-needed object fields). | Zero (Returns only requested fields). |
| Network Round-Trips | High (Requires waterfall calls). | Low (Aggregates resources in 1 request). |
| HTTP Caching | Native & Easy (CDN / Browser URLs). | Complex (Requires client Normalized Caches). |
| DB Performance Risk | Predictable (Fixed SQL queries). | High (N+1 queries; un-bounded depth). |
| Schema Contract | Optional (OpenAPI / Swagger). | Mandatory Type-Safe Schema (SDL). |
Schema Federation in Distributed Microservices
In large microservice architectures, maintaining a massive monolithic GraphQL schema file across 50 development teams creates deployment bottlenecks. To decouple schema management, platforms deploy **GraphQL Federation** (such as Apollo Federation). Each microservice defines and owns a local subgraph schema (e.g. `user-subgraph`, `order-subgraph`). A federated **GraphQL Router / Gateway** composes these independent subgraphs into a unified supergraph at runtime. When a client submits a single query document spanning users and orders, the federated router plans execution, fetches sub-queries from underlying microservices in parallel, and merges the JSON results into a single response.Complete Worked Example: Go GraphQL DataLoader Service
Let's inspect a complete Go implementation of a DataLoader batching resolver for the APILab platform (apilab.com) using dataloader.
package main
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/graph-gophers/dataloader/v7"
)
type User struct {
ID string
Name string
}
type UserBatchLoader struct {
db *sql.DB
}
func NewUserBatchLoader(db sql.DB) dataloader.Loader[string, User] {
b := &UserBatchLoader{db: db}
// Define the Batch Loading Function
batchFn := func(ctx context.Context, keys []string) []dataloader.Result[User] {
results := make([]dataloader.Result[User], len(keys))
fmt.Printf("[DATALOADER BATCH] Collapsing %d user queries into 1 SQL IN clause!\n", len(keys))
// Build SQL: SELECT id, name FROM users WHERE id IN ('1', '2', '3'...)
placeholders := make([]string, len(keys))
args := make([]interface{}, len(keys))
for i, key := range keys {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = key
}
query := fmt.Sprintf("SELECT id, name FROM users WHERE id IN (%s)", strings.Join(placeholders, ","))
rows, err := b.db.QueryContext(ctx, query, args...)
if err != nil {
for i := range results {
results[i] = &dataloader.Result[User]{Error: err}
}
return results
}
defer rows.Close()
userMap := make(map[string]*User)
for rows.Next() {
var u User
rows.Scan(&u.ID, &u.Name)
userMap[u.ID] = &u
}
// Map results back to original keys order
for i, key := range keys {
if u, found := userMap[key]; found {
results[i] = &dataloader.Result[User]{Data: u}
} else {
results[i] = &dataloader.Result[User]{Error: sql.ErrNoRows}
}
}
return results
}
return dataloader.NewBatchedLoader(batchFn)
}
Failure Modes and Engineering Mitigations
| Failure Scenario | Root Cause | System Symptom | Detection Signal | Mitigation Strategy |
|---|---|---|---|---|
| 1. The N+1 Query Outage | Resolvers execute individual database queries for nested record arrays without DataLoader. | Database CPU hits 100%; database active connection pool exhausts completely. | High SQL QPS spikes accompanying low GraphQL request volume. | Mandatory deployment of DataLoader Batching on all nested relationship resolvers. |
| 2. Un-Bounded Query Depth Attack | Malicious client submits an infinitely nested recursive query (user { friends { friends { friends ... } } }). | Server memory inflation and CPU lockup trying to evaluate recursive AST trees. | Sudden spike in GraphQL query execution latency ($> 10\text{s}$). | Enforce Max Query Depth Limits (e.g. max depth = 5) and Query Complexity Weight Analysis at the gateway layer. |
| 3. Loss of HTTP Network Caching | All requests POST to /graphql; standard CDN edge proxies treat POST as non-cacheable. | Increased origin server load; inability to cache public static catalog data at edge CDNs. | Low CDN hit ratio on GraphQL endpoints. | Use Persisted Queries (hash-based GET requests /graphql?hash=a7f9...) for cacheable queries. |
| 4. Verbose Failure Disguise (HTTP 200 Errors) | GraphQL returns HTTP 200 OK with an internal "errors" JSON payload array. | Monitoring tools log 100% success while users experience application component breakage. | High client error rates despite 0% HTTP 5xx error metrics. | Configure APM observability tools to inspect the JSON response body for the "errors" array key. |
What You Should Remember
- GraphQL eliminates over-fetching and under-fetching: Clients request exact fields in a single POST document, avoiding heavy JSON payloads and sequential network waterfalls.
- GraphQL uses a single endpoint: Rather than exposing dozens of REST URIs, GraphQL exposes
POST /graphqlbacked by a strongly typed Schema SDL. - Always deploy DataLoader for N+1 queries: Without DataLoader batching, nested array queries execute $1 + N$ individual database lookups, crashing the primary database.
- Enforce Max Query Depth limits: Protect GraphQL servers against Denial-of-Service attacks by restricting max query nesting depth and query complexity weights.
- Use Persisted Queries for CDN caching: Convert HTTP POST query documents to GET hashes (
/graphql?hash=...) to enable edge CDN caching for public read data.
Glossary of Terms
| Term | Definition |
|---|---|
| GraphQL | A client-driven query language and server runtime for executing API queries against a strongly typed schema. |
| Over-Fetching | The state where an API returns more data fields than the client application needs. |
| Under-Fetching | The state where an API returns insufficient data, forcing clients to make multiple sequential network calls. |
| Schema Definition Language (SDL) | The syntax used to define GraphQL types, fields, inputs, and root queries. |
| Resolver | A backend function responsible for fetching data for a specific field in a GraphQL schema. |
| N+1 Query Problem | A performance defect where a query executes 1 root database query followed by N individual child queries. |
| DataLoader | A utility that batches and deduplicates individual data loading requests within an event loop tick. |
| Persisted Queries | A technique where clients send a cryptographic hash of a query instead of the full query document string. |
Practice Scenario and Self-Assessment
Architecture Scenario
You are building a news feed screen for a social network (`sociallab.com`):- The feed displays 50 posts.
- Each post displays the author's
nameandavatarUrl, 3 top comments, and comment authornames.
- Formulate the REST endpoints required to render this screen, and explain the over-fetching and under-fetching trade-offs.
- Write the GraphQL query document to fetch this screen in 1 network call, and calculate the total database queries required with vs without DataLoader batching.
Interactive Self-Assessment
The server fetches N parent items in 1 query, then executes N separate individual queries to resolve child relationships.
GraphQL disables database connection pooling over TCP sockets.
The JSON parser fails to decode nested object arrays in memory.
HTTP/1.1 limits database engines to processing 1 query per second.
It queues and deduplicates child lookup keys within an event loop tick, executing a single batched SQL 'WHERE id IN (...)' query.
It stores all database table records in client browser localStorage.
It converts GraphQL schemas into RESTful OpenAPI YAML specifications.
It encrypts database query strings using AES-256 encryption.
What to Learn Next
- REST vs RPC — Wire Protocol Trade-offs: Compare REST JSON vs gRPC Protobuf binary communication.
- API Gateway — Edge Entry for Microservices: Explore edge routing and BFF aggregation patterns.
- API Design — Clear Contracts Clients Can Trust: Revisit RESTful resource design conventions.
Track: Software Design and Architecture
Previous: Rate Limiter Design — Case Study
Next: REST vs. RPC — Wire Protocol and API Paradigm Trade-offs
By Shubham Jain