GraphQL is a query language and runtime for APIs. The client specifies what data it needs; the server returns exactly that. A single endpoint replaces dozens of REST endpoints. The schema is first-class; tooling is excellent. The trade-off: more complex than REST.
This page is about how GraphQL works in practice and when to use it.
A GraphQL schema defines types and their fields:
type Order {
id: ID!
amount: Float!
status: OrderStatus!
customer: Customer!
items: [OrderItem!]!
}
enum OrderStatus {
PENDING
SHIPPED
DELIVERED
CANCELLED
}
type Query {
order(id: ID!): Order
orders(status: OrderStatus, limit: Int = 50): [Order!]!
}
type Mutation {
createOrder(input: CreateOrderInput!): Order!
cancelOrder(id: ID!): Order!
}
The schema is the contract. Clients query it; servers implement it.
Clients request specific fields:
query {
order(id: "abc") {
id
amount
customer {
name
email
}
}
}
Response contains only the requested fields. No over-fetching.
Same field, different arguments:
query {
pending: orders(status: PENDING) { id }
shipped: orders(status: SHIPPED) { id }
}
Reusable field selections:
fragment OrderSummary on Order {
id
amount
status
}
query {
order(id: "1") { ...OrderSummary }
orders { ...OrderSummary }
}
Parameterized queries (the right way; not string interpolation):
query GetOrder($id: ID!) {
order(id: $id) { id amount }
}
Operations that modify data:
mutation {
createOrder(input: { customerId: "abc", amount: 100.00 }) {
id
status
}
}
Same schema/query mechanics as queries; convention separates them so it's clear what modifies state.
Real-time data via WebSocket (typically):
subscription {
orderStatusChanged(orderId: "abc") {
id
status
}
}
The connection stays open; the server pushes events. See WebSocketPatterns.
GraphQL's flexibility creates a server-side performance issue. Consider:
query {
orders {
id
customer { name }
}
}
Naive resolution:
Total: N+1 queries.
The standard solution. DataLoader batches requests within a single tick of the event loop:
const customerLoader = new DataLoader(async (ids) => {
const customers = await db.customers.findByIds(ids);
return ids.map(id => customers.find(c => c.id === id));
});
// In resolver:
customer: (order) => customerLoader.load(order.customerId)
Each load() call queues the ID; at the end of the tick, all queued IDs are fetched in one query. N+1 becomes 2.
DataLoader is essential for any non-trivial GraphQL server.
Field-level authorization is a real complication. Each field can have different permissions; the resolver enforces them.
Two patterns:
@auth(role: ADMIN) annotations on schema fieldsFor complex authorization, schema-level approaches help; for simple cases, resolver checks are clearest.
Three styles, in order of decreasing convenience and increasing scalability:
orders(limit: 50, offset: 100): [Order!]!
Simple but inefficient at deep pages.
orders(first: 50, after: "cursor123"): OrderConnection!
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
Relay-style. Faster for deep pagination; preserves order under inserts.
orders(page: 5, perPage: 50): [Order!]!
Familiar; less efficient than cursor-based.
See PaginationStrategies.
Mutation, not as side-effecting Query resolvers.