GraphQL #
GraphQL is often either adopted too quickly or rejected too quickly. Some teams adopt it because it sounds modern, without any real need justifying its added complexity. Others reject it because “REST is enough”, even when there are real problems GraphQL solves far more elegantly. To decide correctly, you need to understand where GraphQL came from, what problems it was designed to solve, how it works, and what costs come with it. This article covers all of that — from schema design to DataLoader, from query complexity limiting to when you should just stick with REST.
Why GraphQL Exists #
Facebook built GraphQL in 2012 to solve a real problem in their mobile app. The Facebook news feed needs data from dozens of different sources — posts, photos, videos, comments, reactions, friend information — and every UI component needs a different data subset.
With REST, there are two equally bad options:
Option 1: One endpoint per UI need
GET /newsfeed/posts
GET /newsfeed/photos
GET /newsfeed/friends-activity
→ Many requests, many round trips, slow on mobile
→ Every UI change requires a new backend endpoint
Option 2: One big endpoint returning all data
GET /newsfeed
→ Over-fetching: mobile receives data not all of which is used
→ Wastes bandwidth on limited mobile networks
→ Slow responses
GraphQL is the solution to this dilemma: one endpoint, but the client decides exactly what data it needs. Facebook used it internally for three years before open-sourcing it in 2015.
flowchart LR
subgraph REST["REST — the server defines the data"]
RC["Client"]
RE1["GET /users/1\n→ the entire user profile"]
RE2["GET /users/1/posts\n→ all posts"]
RE3["GET /users/1/followers\n→ all followers"]
RC -->|requests| RE1
RC -->|requests| RE2
RC -->|requests| RE3
end
subgraph GQL["GraphQL — the client defines the data"]
GC["Client"]
GE["POST /graphql\n→ only the requested fields"]
GC -->|request| GE
endHow GraphQL Works #
Schema — The Contract Defining Everything #
In GraphQL, the schema is the single source of truth. It defines all available types, relationships between types, and operations that can be performed. Schemas are written in SDL (Schema Definition Language).
# Type definitions
type User {
id: ID!
name: String!
email: String!
createdAt: String!
posts: [Post!]!
followers: [User!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
publishedAt: String
tags: [String!]!
}
# Root types — the entry points for all operations
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
The ! mark means non-nullable — that field is guaranteed not to be null if the request succeeds.
The Three GraphQL Operations #
flowchart TD
subgraph Ops["Three GraphQL Operations"]
Q["Query\nRead data\n(like GET in REST)"]
M["Mutation\nChange data\n(like POST/PUT/DELETE in REST)"]
S["Subscription\nReal-time updates\n(like WebSocket)"]
end
Q -->|"query { user(id: 1) { name } }"| Qex["Response:\n{ user: { name: 'Budi' } }"]
M -->|"mutation { createPost(input: {...}) { id } }"| Mex["Response:\n{ createPost: { id: '123' } }"]
S -->|"subscription { postCreated { id title } }"| Sex["Push notifications\nwhen a new post arrives"]Queries — the Client Defines the Data Shape #
# The client requests exactly the data it needs
query GetUserProfile {
user(id: "123") {
name
email
posts {
title
publishedAt
}
followers {
name
}
}
}
# Response — only the requested data, nothing more
{
"data": {
"user": {
"name": "Budi Santoso",
"email": "[email protected]",
"posts": [
{ "title": "Learning GraphQL", "publishedAt": "2024-01-15" }
],
"followers": [
{ "name": "Ani" },
{ "name": "Candra" }
]
}
}
}
Mutations — Changing Data #
# Mutation with an input type
mutation CreateNewPost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
author {
name
}
}
}
# Variables
{
"input": {
"title": "GraphQL Guide",
"content": "...",
"tags": ["graphql", "api"]
}
}
Variables — Separating Queries from Data #
# ANTI-PATTERN: Hardcoding values directly in queries (unsafe, not reusable)
query {
user(id: "123") { name }
}
# CORRECT: Use variables
query GetUser($userId: ID!) {
user(id: $userId) { name }
}
# Variables sent separately: { "userId": "123" }
# Safer (prevents injection), more reusable
The N+1 Problem and the DataLoader Solution #
This is the most critical problem to understand before implementing GraphQL in production. The N+1 query problem is a condition where one GraphQL query produces N+1 database queries.
sequenceDiagram
participant C as Client
participant R as GraphQL Resolver
participant DB as Database
C->>R: query { posts { title author { name } } }
Note over R,DB: Without DataLoader — N+1 problem
R->>DB: SELECT * FROM posts LIMIT 10
DB-->>R: 10 posts
loop For every post
R->>DB: SELECT * FROM users WHERE id = ?
DB-->>R: 1 user
end
Note over R,DB: Total: 1 + 10 = 11 database queries!
Note over R,DB: With DataLoader — only 2 queries
R->>DB: SELECT * FROM posts LIMIT 10
DB-->>R: 10 posts
R->>DB: SELECT * FROM users WHERE id IN (1,2,3,...,10)
DB-->>R: 10 users at onceWhy N+1 Happens #
Every resolver in GraphQL operates independently. The author resolver on the Post type doesn’t know it will be called 10 times for 10 different posts — it only knows “I need to fetch the user with this id”.
// ANTI-PATTERN: A naive resolver
const resolvers = {
Post: {
author: async (post) => {
// This is called ONCE PER POST → N+1 problem
return await db.users.findById(post.authorId);
}
}
}
// CORRECT: With DataLoader
const userLoader = new DataLoader(async (userIds) => {
// Called ONCE with all the IDs at once
const users = await db.users.findByIds(userIds);
return userIds.map(id => users.find(u => u.id === id));
});
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId)
// DataLoader collects all IDs within one tick,
// then performs one batch query
}
}
DataLoader does two things: batching (collecting individual requests into one batch query) and caching (the same result within one request isn’t fetched twice).
Good Schema Design #
A good schema reflects the business domain, not the database structure. This is the most important difference between a schema that evolves easily and one that becomes technical debt.
Domain-Driven, Not Database-Driven #
# ANTI-PATTERN: A schema reflecting the database
type User {
user_id: Int! # database column name
created_timestamp: Int # Unix timestamp from the DB
is_deleted: Boolean # soft delete flag from the DB
role_id: Int # foreign key from the DB
}
# CORRECT: A schema reflecting the domain
type User {
id: ID!
createdAt: String!
status: UserStatus! # meaningful enum
role: UserRole! # rich type, not an ID
}
enum UserStatus {
ACTIVE
SUSPENDED
DEACTIVATED
}
enum UserRole {
ADMIN
EDITOR
VIEWER
}
Input Types for Mutations #
# ANTI-PATTERN: Many individual arguments
mutation {
createUser(name: String!, email: String!, role: UserRole!, teamId: ID!)
}
# CORRECT: Use input types — easier to evolve
input CreateUserInput {
name: String!
email: String!
role: UserRole!
teamId: ID!
}
mutation {
createUser(input: CreateUserInput!): User!
}
# Adding a new field to CreateUserInput isn't a breaking change
Cursor-Based Pagination #
# Cursor-based pagination (recommended for GraphQL)
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
# Query:
query {
users(first: 10, after: "cursor123") {
edges {
node { id name }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
This pattern is known as the Relay Cursor Connection Spec and is the de-facto standard for GraphQL pagination.
Query Complexity and Depth Limiting #
Without restrictions, clients can send very expensive queries to the server:
# A query that could collapse the server
query MaliciousQuery {
users {
followers {
followers {
followers {
followers {
posts {
comments {
author {
followers { name }
}
}
}
}
}
}
}
}
}
There are two main strategies for preventing this:
Depth Limiting #
Limit query nesting depth:
Depth limit: 5
The query above has a depth of 8 → rejected before execution
Implementation (example with graphql-depth-limit in Node.js):
import depthLimit from 'graphql-depth-limit'
const server = new ApolloServer({
validationRules: [depthLimit(5)]
})
Complexity Scoring #
Give every field a "cost":
Scalar field: 1 point
Object field: 1 point
List field: 10 points (because it can return many items)
The query above:
users (list): 10
followers (list) × users: can be very large
Maximum complexity: for example 1000 points
Queries exceeding the limit → rejected with a clear error:
"Query complexity 4320 exceeds maximum allowed complexity of 1000"
Query complexity limiting must be implemented before a GraphQL API is exposed publicly or even to uncontrolled internal consumers. Without it, one accidentally or deliberately expensive query can make the entire service unresponsive. This isn’t an optimization — it’s basic security.
Caching in GraphQL #
Caching is one of GraphQL’s biggest challenges because all requests use POST to a single endpoint — traditional HTTP caching can’t be leveraged directly.
Available Caching Strategies #
1. Persisted Queries
Clients send a hash of the query instead of the full query string
→ Can be executed via GET with the hash as a parameter
→ GET requests can be cached by CDNs and HTTP caches
GET /graphql?operationName=GetUser&extensions={"persistedQuery":{"sha256Hash":"abc123"}}
2. Server-Side Response Caching
Cache resolver results per field or per query
→ Redis or in-memory caching at the resolver level
→ Needs a proper invalidation strategy
@cacheControl(maxAge: 300) # cache 5 minutes
type Product {
id: ID!
name: String!
price: Float! @cacheControl(maxAge: 30) # changes more often
}
3. Client-Side Caching
Apollo Client and Relay have sophisticated normalized caches
→ Store data per entity, not per query
→ Updating one entity automatically refreshes all queries using it
4. DataLoader Caching
Already discussed — per-request caching to avoid
fetching the same data twice within one request
Schema Versioning and Evolution #
One of GraphQL’s big advantages is its ability to evolve without explicit versioning — but this requires discipline.
# Adding a new field → SAFE, not breaking
type User {
id: ID!
name: String!
email: String!
phoneNumber: String # new field, nullable → old clients unaffected
}
# Renaming a field → BREAKING (use deprecation)
type User {
id: ID!
name: String!
username: String @deprecated(reason: "Use 'name' instead. Will be removed 2025-01-01")
# Still present for backward compatibility, but marked deprecated
}
# Removing a field → ONLY after a sufficient deprecation period
# Step 1: mark deprecated + communicate to consumers
# Step 2: monitor whether it's still used (via field usage metrics)
# Step 3: remove once nobody uses it
A healthy deprecation timeline:
Month 1: Add the new field, deprecate the old one
Months 2-4: Monitor old field usage through observability
Month 5: If usage is zero → remove the field
If still in use → communicate to consumers again
Monitoring and Observability #
GraphQL needs different monitoring than REST. In REST, you can monitor per endpoint (/users, /orders). In GraphQL, all requests go to one endpoint — what needs monitoring is per operation.
Metrics to track:
Per operation (not per endpoint):
→ Latency per query/mutation (P50, P95, P99)
→ Error rate per operation
→ Frequency each operation is called
Per field/resolver:
→ Resolver latency per field
→ Field usage — which fields are never requested?
(candidates for schema removal)
System level:
→ Query complexity distribution
→ Depth distribution
→ DataLoader batch sizes
Commonly used tools:
→ Apollo Studio (operation tracking, field usage)
→ GraphQL Yoga with custom plugins
→ Prometheus + Grafana for existing systems
Field usage metrics are very valuable for schema evolution — you can know with certainty whether a field is still used before removing it. Apollo Studio provides this feature built-in. Without this visibility, schemas grow without ever being prunable.
Hybrid Architecture — GraphQL and REST Together #
GraphQL doesn’t have to fully replace REST. A hybrid approach is often more pragmatic and healthier.
flowchart TD
WebClient["Web Client (React)"]
MobileClient["Mobile Client (Flutter)"]
GQL["GraphQL API\n(BFF Layer)"]
UserSvc["User Service\n(REST/gRPC)"]
OrderSvc["Order Service\n(REST/gRPC)"]
ProductSvc["Product Service\n(REST/gRPC)"]
PaymentSvc["Payment Service\n(REST/gRPC)"]
WebClient -->|"GraphQL Query"| GQL
MobileClient -->|"GraphQL Query"| GQL
GQL -->|"Internal REST/gRPC"| UserSvc
GQL -->|"Internal REST/gRPC"| OrderSvc
GQL -->|"Internal REST/gRPC"| ProductSvc
GQL -->|"Internal REST/gRPC"| PaymentSvcThis pattern is known as BFF (Backend for Frontend) — GraphQL acts as the aggregation layer facing clients, while the services behind it keep using REST or gRPC for internal communication.
The advantage: clients get GraphQL flexibility, while internal services stay simple and cacheable with traditional HTTP caching.
GraphQL Anti-Patterns to Avoid #
Schemas Reflecting the Database #
# ✗ Schemas leaking database details:
type OrderItem {
order_item_id: Int! # database primary key
order_fk: Int! # foreign key
product_fk: Int! # foreign key
qty: Int! # abbreviation
unit_price_cents: Int! # implementation detail (cents)
}
# ✓ Domain-friendly schemas:
type OrderItem {
id: ID!
order: Order! # direct relation, not an FK
product: Product! # direct relation, not an FK
quantity: Int! # full name
unitPrice: Money! # rich type
}
type Money {
amount: Float!
currency: String!
}
God Queries — One Query for Everything #
# ✗ A god query fetching all data at once:
query GetEverything {
currentUser {
profile { ... }
orders { ... }
notifications { ... }
recommendations { ... }
recentSearches { ... }
savedItems { ... }
}
}
→ Slow because all data is fetched at once even when not all is displayed
# ✓ Queries specific to pages or components:
# On the profile page:
query GetUserProfile { currentUser { profile { ... } } }
# On the orders page:
query GetUserOrders { currentUser { orders { ... } } }
# In the notification sidebar:
query GetNotifications { currentUser { notifications { ... } } }
No Proper Error Handling #
# ✗ GraphQL always returns 200 OK, even for errors
# Without proper handling, errors are easy to miss:
{
"data": {
"user": null ← null without explaining why
}
}
# ✓ Use explicit error handling:
{
"data": {
"user": null
},
"errors": [
{
"message": "User not found",
"extensions": {
"code": "USER_NOT_FOUND",
"path": ["user"]
}
}
]
}
# Or use union types for predictable errors:
union UserResult = User | UserNotFoundError | UnauthorizedError
type Query {
user(id: ID!): UserResult!
}
GraphQL as an ORM over HTTP #
# ✗ GraphQL too close to the database:
mutation {
updateUser(
where: { id: { eq: "123" } }
set: { name: "Budi", email: "[email protected]" }
)
}
→ This is a database query, not an API
→ Business logic, validation, and authorization are bypassed
# ✓ Mutations reflecting domain operations:
mutation {
updateUserProfile(input: {
userId: "123"
name: "Budi"
email: "[email protected]"
})
}
→ The resolver applies business rules and validation
When to Use GraphQL vs REST #
Use GraphQL when there's a real need for:
✓ Multiple different clients (web, mobile, widgets) with different data needs
✓ Very dynamic, data-hungry UIs
✓ Over-fetching is already a measurable problem (not an assumption)
✓ Frontend teams need iteration speed not dependent on the backend
✓ Aggregating data from several services (BFF pattern)
Keep using REST if:
✓ Simple APIs with clear, stable endpoints
✓ Public APIs needing full HTTP cacheability
✓ The team isn't familiar with GraphQL and has no bandwidth to learn
✓ The main use cases are simple CRUD without significant query variations
✓ Simple file uploads are needed (REST is more natural for this)
Signs GraphQL might be over-engineering:
✗ Only one client type exists (e.g. web only)
✗ Data structures on each page don't differ much
✗ A small team with good velocity is already using REST
✗ No real, measurable over-fetching problem
Healthy GraphQL Checklist #
SCHEMA DESIGN:
□ Schema reflects the business domain, not the database schema
□ Field names camelCase and consistent
□ Input types used for all mutations
□ Enums used for limited values (not free strings)
□ Nullable vs non-nullable carefully considered
PERFORMANCE AND SECURITY:
□ DataLoader implemented for all relations that could be N+1
□ Query depth limiting enabled
□ Query complexity scoring implemented
□ Rate limiting applied (per IP or per token)
□ Introspection disabled in production (unless there's a reason)
CACHING:
□ Persisted queries implemented for production
□ Resolver-level caching for rarely changing data
□ Cache invalidation strategy thought through
SCHEMA EVOLUTION:
□ Deprecation used before removing fields
□ Field usage monitored before removing
□ Nullable fields for new optional fields
□ Input types make adding new parameters easy
MONITORING:
□ Latency per operation monitored (not just per endpoint)
□ Resolver latency monitored to find bottlenecks
□ Error rate per operation monitored
□ Field usage metrics available for schema governance
ERROR HANDLING:
□ Error extensions contain machine-readable codes
□ Union types used for expected errors (not just null)
□ Partial success clearly documented
Summary #
- GraphQL was born to solve over-fetching and under-fetching — not to replace REST. Use it if there’s a real problem justifying its added complexity.
- The schema is the contract — design for the domain, not the database — field names reflect the business, explicit relations (not FKs), enums for limited values, input types for mutations.
- The N+1 query problem is the biggest threat in production — DataLoader must be implemented for every relation before going to production. Without it, one simple query can produce hundreds of database queries.
- Query complexity limiting isn’t an optimization, it’s basic security — depth limits and complexity scoring must exist before the API is exposed. One uncontrolled query can collapse the service.
- Caching in GraphQL needs a different strategy — persisted queries for CDN caching, resolver caching with Redis, and client-side normalized caching with Apollo/Relay.
- Schemas evolve through deprecation, not versioning — add new fields, mark old fields deprecated, monitor usage, then remove once nobody uses them.
- Monitor per operation, not per endpoint — one
/graphql endpoint hides hundreds of different operations. Use tooling that breaks down per operation. - A GraphQL + REST/gRPC hybrid is often the healthiest pattern — GraphQL as the BFF layer facing clients, internal services staying on REST or gRPC.
- GraphQL isn’t an ORM over HTTP — resolvers must apply business logic, validation, and authorization. Schemas too close to the database violate abstraction.
- Disable introspection in production — a queryable schema is a map for attackers wanting to find sensitive fields or plan complex DoS queries.
#
- GraphQL was born to solve over-fetching and under-fetching — not to replace REST. Use it if there’s a real problem justifying its added complexity.
- The schema is the contract — design for the domain, not the database — field names reflect the business, explicit relations (not FKs), enums for limited values, input types for mutations.
- The N+1 query problem is the biggest threat in production — DataLoader must be implemented for every relation before going to production. Without it, one simple query can produce hundreds of database queries.
- Query complexity limiting isn’t an optimization, it’s basic security — depth limits and complexity scoring must exist before the API is exposed. One uncontrolled query can collapse the service.
- Caching in GraphQL needs a different strategy — persisted queries for CDN caching, resolver caching with Redis, and client-side normalized caching with Apollo/Relay.
- Schemas evolve through deprecation, not versioning — add new fields, mark old fields deprecated, monitor usage, then remove once nobody uses them.
- Monitor per operation, not per endpoint — one
/graphqlendpoint hides hundreds of different operations. Use tooling that breaks down per operation. - A GraphQL + REST/gRPC hybrid is often the healthiest pattern — GraphQL as the BFF layer facing clients, internal services staying on REST or gRPC.
- GraphQL isn’t an ORM over HTTP — resolvers must apply business logic, validation, and authorization. Schemas too close to the database violate abstraction.
- Disable introspection in production — a queryable schema is a map for attackers wanting to find sensitive fields or plan complex DoS queries.