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):


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:

  1. The root resolver executes 1 database query to fetch 100 order records (SELECT FROM orders LIMIT 100).
  2. For each of the 100 orders, the GraphQL engine invokes the author field resolver individually.
  3. 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 FeatureREST ArchitectureGraphQL Architecture
Endpoint TopologyMultiple URIs (/users, /orders).Single Endpoint (POST /graphql).
Data Fetching ControlServer-Defined fixed JSON shape.Client-Defined flexible query document.
Over-FetchingCommon (Returns un-needed object fields).Zero (Returns only requested fields).
Network Round-TripsHigh (Requires waterfall calls).Low (Aggregates resources in 1 request).
HTTP CachingNative & Easy (CDN / Browser URLs).Complex (Requires client Normalized Caches).
DB Performance RiskPredictable (Fixed SQL queries).High (N+1 queries; un-bounded depth).
Schema ContractOptional (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 ScenarioRoot CauseSystem SymptomDetection SignalMitigation Strategy
1. The N+1 Query OutageResolvers 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 AttackMalicious 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 CachingAll 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 &quot;errors&quot; 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 &quot;errors&quot; array key.

What You Should Remember

  1. GraphQL eliminates over-fetching and under-fetching: Clients request exact fields in a single POST document, avoiding heavy JSON payloads and sequential network waterfalls.
  2. GraphQL uses a single endpoint: Rather than exposing dozens of REST URIs, GraphQL exposes POST /graphql backed by a strongly typed Schema SDL.
  3. Always deploy DataLoader for N+1 queries: Without DataLoader batching, nested array queries execute $1 + N$ individual database lookups, crashing the primary database.
  4. Enforce Max Query Depth limits: Protect GraphQL servers against Denial-of-Service attacks by restricting max query nesting depth and query complexity weights.
  5. 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

TermDefinition
GraphQLA client-driven query language and server runtime for executing API queries against a strongly typed schema.
Over-FetchingThe state where an API returns more data fields than the client application needs.
Under-FetchingThe 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.
ResolverA backend function responsible for fetching data for a specific field in a GraphQL schema.
N+1 Query ProblemA performance defect where a query executes 1 root database query followed by N individual child queries.
DataLoaderA utility that batches and deduplicates individual data loading requests within an event loop tick.
Persisted QueriesA 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`): **Questions**:
  1. Formulate the REST endpoints required to render this screen, and explain the over-fetching and under-fetching trade-offs.
  2. 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

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

All articles · Study paths

Shubham Jain · Learning Lab