GraphQL
Query language for APIs — clients ask for exactly what they need
GraphQL is a query language and runtime for APIs that shifts control from the server to the client. Where REST has fixed response shapes defined by the server, GraphQL lets clients declare the exact fields they want — and fetch them in a single request. The server returns a JSON object that mirrors the query structure exactly.
The schema is the contract: a typed, introspectable definition of every object, field, mutation, and subscription in your API. Resolvers execute the query by traversing the schema, calling functions for each field, and assembling the response. The power — and the challenge — is that GraphQL executes an arbitrary graph traversal per request, not a fixed resource hierarchy.
Anatomy of a Query
A GraphQL operation names the operation type, optionally names the operation, declares variables, and selects fields. The response shape mirrors the query structure exactly.
Query (client → server)
query GetUser($id: ID!, $includeEmail: Boolean!) {
user(id: $id) {
id
name
email @include(if: $includeEmail)
posts(first: 5) {
title
commentCount # aliasing to avoid conflict
tags
author {
name
}
}
}
}
# Variables:
# { "id": "123", "includeEmail": true } Response (server → client)
{
"data": {
"user": {
"id": "123",
"name": "Ada Lovelace",
"email": "[email protected]",
"posts": [
{
"title": "How I invented the first compiler",
"commentCount": 47,
"tags": ["history", "compilers"],
"author": { "name": "Ada Lovelace" }
}
]
}
}
} Operation Types
Schema Definition Language (SDL)
The schema describes every type and operation in your API using SDL (Schema Definition Language). It's self-documenting and serves as the contract between frontend and backend teams.
type User {
id: ID!
name: String!
email: String
createdAt: DateTime!
posts: [Post!]!
role: UserRole!
}
enum UserRole {
ADMIN
EDITOR
VIEWER
}
type Post {
id: ID!
title: String!
body: String!
published: Boolean!
author: User!
tags: [String!]!
comments(limit: Int = 10): [Comment!]!
createdAt: DateTime!
}
interface Comment {
id: ID!
body: String!
createdAt: DateTime!
}
type TextComment implements Comment {
id: ID!
body: String!
createdAt: DateTime!
edited: Boolean!
}
union SearchResult = User | Post | Comment
input CreatePostInput {
title: String!
body: String!
tags: [String!]
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
search(query: String!): [SearchResult!]!
posts: [Post!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
deletePost(id: ID!): Boolean!
updatePost(id: ID!, input: UpdatePostInput!): Post
}
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
scalar DateTime Type Markers
String!— non-nullable (always returns a value)[Post!]— list of nullable Post objects[Post!]!— non-nullable list of non-nullable PostsID!— unique identifier scalar
What Each Schema Element Means
type— object with fieldsinterface— abstract type with shared fieldsunion— "is one of" (no shared fields)input— complex argument shapeenum— closed set of string valuesscalar— custom leaf value (DateTime, JSON)
Resolver Chain — How a Query Executes
When a query arrives, the GraphQL runtime traverses it field by field, calling resolver functions at each step. Each resolver returns a value (primitive or object), and the runtime continues down the tree until it reaches leaf fields.
// Query: user(id: "123") { name posts(first: 2) { title author { name } } }
// Execution trace:
Query.user → calls user(id: "123") resolver → User object
User.name → calls name resolver on User obj → "Ada Lovelace" (String)
User.posts → calls posts resolver (first: 2) → [Post, Post]
Post.title → calls title on each Post in list → ["First Post", "Second"]
Post.author → calls author resolver on each Post → [User, User]
User.name → calls name on each returned User → ["Ada Lovelace", "Alan T."]
// All of the above runs synchronously per request tick // Default resolver behavior (Apollo Server)
const resolvers = {
Query: {
user(parent, args, context, info) {
// parent = parent resolver's return value
// args = { id: "123" }
// context = { user: req.user, db: context.db } (per-request)
// info = { query, fragments, variableValues, operationName }
return context.db.users.findById(args.id);
}
},
User: {
posts(user, args, context, info) {
// parent = the User object returned by Query.user resolver
// args = { first: 2 }
return context.db.posts.findByAuthorId(user.id, { limit: args.first });
},
// name: (user) => user.name ← default resolver uses parent[name]
}
}; The four resolver arguments
The N+1 Problem
N+1 is GraphQL's biggest performance footgun. It arises because each field resolves independently, and a naive resolver fetches data at the moment it's requested, with no knowledge of what other fields need the same data.
The Problem
// Query with a nested list:
query {
users {
posts {
comments {
author {
name // ← this field is fetched per comment
}
}
}
}
}
// Naive resolver execution:
// SELECT * FROM users; → 1 query
// for each user: SELECT * FROM posts → N queries
// for each post: SELECT * FROM comments → N×M queries
// for each comment: SELECT * FROM users → N×M×K queries
// Total: 1 + N + N×M + N×M×K + ... ← explodes fast DataLoader Solution
// DataLoader batches all user ID lookups
// across a single event-loop tick into one query
const userLoader = new DataLoader(keys =>
db.users.findByIds(keys) // fired once, all IDs
);
const resolvers = {
User: {
posts(user) {
return postLoader.loadMany(user.postIds);
},
name(user) { return userLoader.load(user.id); }
}
};
// Before: N user lookups = N DB round trips
// After: N user lookups = 1 batched DB query Why REST Doesn't Have This Problem
REST responses are statically shaped by the endpoint — the server knows in advance what
related resources to include. A GET /users/123?include=posts,comments endpoint
can JOIN posts and comments in SQL and return everything in 1–2 queries. The server controls
the response shape, so it can plan the data fetch. In GraphQL, the client controls the shape,
so the server can't know which related resources will be requested until it parses the query.
DataLoader: Batching + Caching
DataLoader is Facebook's open-source utility that solves N+1 through two mechanisms: batching (collecting IDs across resolver calls within a tick) and caching (same-request deduplication). It's not part of the GraphQL spec but is the de facto standard for production GraphQL servers.
// Classic N+1 scenario: user with posts, each post with author
// Without DataLoader: 1 + N + N queries (N posts, each author)
// With DataLoader: 1 + 2 queries (one for posts, one batched for all authors)
class DataLoader<K, V> {
constructor(batchLoadFn: (keys: K[]) => Promise<V[]>)
load(key: K): Promise<V>
loadMany(keys: K[]): Promise<V[]>
// clear(key) — invalidate a cache entry
// prime(key, value) — pre-populate cache
}
// Usage in resolver context (per-request lifecycle):
function createContext({ db }) {
return {
db,
userLoader: new DataLoader(keys => db.users.findByIds(keys)),
postLoader: new DataLoader(keys => db.posts.findByIds(keys)),
authorLoader: new DataLoader(keys => db.users.findByIds(keys)), // reuses same table
};
}
// All DataLoaders are fresh per request — no cross-request cache pollution
// Cache lives for the duration of one query execution How Batching Works — Step by Step
// In a single resolver execution:
// Query: users { posts { author { name } } }
// Step 1: Query.user resolver fires, returns [user1, user2, user3]
// Step 2: User.posts resolver fires 3 times (once per user)
// DataLoader.loadMany([id1, id2, id3]) → queued, not yet fired
// But DataLoader waits for the event loop to drain before flushing
// Step 3: Three post objects returned [post1, post2, post3]
// Step 4: Post.author resolver fires 3 times
// authorLoader.load(authorId) → queued (now 6 pending IDs)
// Step 5: Event loop drains → batchLoadFn([...all IDs]) fires once
// Step 6: Results distributed back to each .load() caller
// Without batching: 1 + 3 + 3 = 7 DB round trips
// With batching: 1 + 2 = 3 DB round trips load(123) is called twice within one query, only one DB call fires.
But after the response is sent, the DataLoader is garbage-collected. Cross-request caching
is Apollo Client's job (normalized cache by entity ID), not the server-side DataLoader's.
DataLoader API Reference
// load(key) — single key → Promise<V>
// loadMany(keys) — K[] → Promise<V[]> (maintains order, skips missing)
// clear(key) — invalidate cache entry, next load() fires batch again
// prime(key, value) — pre-load cache (useful for test setups)
// clearAll() — wipe all cache and pending batches
// Batch function must return results in same order as input keys
// and same length. Missing items → throw, or return null for nullable fields.
const userLoader = new DataLoader(async keys => {
const users = await db.query(`SELECT * FROM users WHERE id = ANY($1)`, [keys]);
const userMap = new Map(users.map(u => [u.id, u]));
return keys.map(id => userMap.get(id) ?? null); // must match key order
}); Schema Types Deep Dive
ObjectType — the core building block
// In SDL:
type User {
id: ID!
name: String!
email: String # nullable — server might not have it
}
// In JS (Apollo Server / graphql-js):
const UserType = new GraphQLObjectType({
name: 'User',
fields: {
id: { type: GraphQLNonNull(GraphQLID) },
name: { type: GraphQLNonNull(GraphQLString) },
email: { type: GraphQLString }, // nullable — no GraphQLNonNull wrapper
}
}); Interfaces vs Unions — when to use each
// Interface — when types share fields and you want to query shared fields
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type Post implements Node, Timestamped {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
title: String!
}
// Query shared fields without knowing concrete type:
query {
search(text: "graphql") {
... on Node { id } // inline fragment on interface
... on Timestamped { updatedAt }
}
}
// Union — when types have NO shared fields (not even id)
union SearchResult = User | Post | Comment
// Must use conditional fragments with unions:
query {
search(text: "graphql") {
... on User { name email }
... on Post { title }
... on Comment { body }
}
} Input Types — mutations and complex arguments
input CreatePostInput {
title: String!
body: String!
tags: [String!]
publishedAt: DateTime
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
// Query:
mutation {
createPost(input: {
title: "Hello GraphQL"
body: "GraphQL is a query language..."
tags: ["graphql", "api"]
}) {
id
title
}
} Enums and Custom Scalars
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
scalar DateTime
scalar JSON
// Custom scalar — you define serialize/parse/parseLiteral
const DateTimeScalar = new GraphQLScalarType({
name: 'DateTime',
serialize(value) { return value instanceof Date ? value.toISOString() : value; },
parseValue(value) { return new Date(value); },
parseLiteral(ast) { return ast.kind === 'StringValue' ? new Date(ast.value) : null; }
}); Mutations — Write Operations
Mutations are GraphQL's equivalent of POST/PUT/PATCH. They follow the same execution model as queries but are identified as side-effect-bearing operations. The input/payload pattern (separate input type from return payload) is the canonical design for production mutation schemas.
// ✅ The canonical pattern: Input + Payload
input CreatePostInput {
title: String!
body: String!
tags: [String!]
}
type CreatePostResult {
post: Post!
success: Boolean!
errors: [UserError!]
}
type UserError {
field: String
message: String!
code: String!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostResult!
}
// Query:
mutation {
createPost(input: { title: "Hello", body: "World" }) {
success
post { id title }
errors { field message }
}
} Optimistic Updates — the client-side pattern
// Apollo Client: optimistic mutation
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post { id title }
}
}
`;
const mutation = useMutation(CREATE_POST);
mutation({
variables: { input: { title: "Hello" } },
optimisticResponse: {
createPost: {
post: { id: null, title: "Hello", __typename: 'Post' },
__typename: 'Mutation'
}
},
update(cache, { data: { createPost } }) {
// Manually write to cache — Apollo doesn't know the schema layout
cache.modify({
id: cache.identify({ __typename: 'Query', id: 'ROOT_QUERY' }),
fields: {
posts(existing = []) {
return [createPost.post, ...existing];
}
}
});
}
});
// User sees the new post immediately. On server error, Apollo rolls back. Mutation execution ordering
By default, GraphQL resolves mutations sequentially (parent field resolves before
children). If you need parallel mutation execution, use the async... pattern:
// Sequential (default) — each waits for the previous
mutation {
createUser(input: { name: "Ada" }) { ... }
createPost(input: { title: "My first post" }) { ... } // waits for createUser
}
// Parallel — fire both at once
async function resolveMutation(...args) {
const [user, post] = await Promise.all([
createUser(args),
createPost(args)
]);
return { user, post };
} Subscriptions — Real-Time Updates
Subscriptions are long-lived operations that push data from server to client over a persistent connection (usually WebSocket). The client subscribes to an event; the server executes the resolver once and then streams results whenever the underlying data changes.
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
userStatusChanged(userId: ID!): UserStatus!
}
// SDL defines the subscription shape
type Post {
id: ID!
title: String!
createdAt: DateTime!
}
// Server implementation (Apollo Server + graphql-ws):
const resolvers = {
Subscription: {
postCreated: {
subscribe: async function*(root, args, context) {
// Returns an AsyncIterable — server yields each new Post
for await (const post of pubsub.asyncIterator('POST_CREATED')) {
yield { postCreated: post };
}
}
},
commentAdded: {
subscribe: async function*(root, { postId }, context) {
// Filter by postId — only yield when matching post gets a comment
const iterable = pubsub.asyncIterator(`COMMENT_ADDED_${postId}`);
for await (const comment of iterable) {
yield { commentAdded: comment };
}
}
}
}
}; Transport Options
Schema Stitching vs Federation
Large organizations split their graph across multiple services. Two patterns exist for composing them back into a single schema: schema stitching (build-time merge) and Apollo Federation (runtime composition with ownership boundaries).
Schema Stitching
Merge two or more schemas at build time. The gateway takes each subschema, transforms types to avoid conflicts, and stitches root fields together.
// Gateway stitches UserService + PostService
const gateway = new ApolloGateway({
serviceList: [
{ name: 'users', url: 'http://localhost:4001' },
{ name: 'posts', url: 'http://localhost:4002' },
]
});
// Problems emerge when:
// - User and Post both define a "User" type
// - Both expose a "latest" Query field
// - Shared enum values differ between services
// Resolution: transform via typeMerging or manual renaming Apollo Federation v2
Each service declares which parts of the schema it owns. The router composes at runtime. Services reference each other's types without duplicating them.
// posts service — owns the Post type, extends User
type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
}
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
// users service — owns User, extends Post via reference
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
// Router query planning:
// Query.posts → posts service
// Post.author → join with users service (@requires) Federation v1 vs v2
@key. @requires to tell router which fields a resolver needs from another service. Implicit schema: services just list types, router infers composition.@shareable, @override, @tag. Entities must be resolved by the owning service. No more implicit cross-service field dependencies. Better tooling, submarine errors.Performance — Query Complexity & Depth
GraphQL's flexibility is a double-edged sword: a single query can traverse thousands of schema nodes. Without guardrails, expensive queries spike CPU, memory, and database load.
Query Complexity Analysis
// Assign complexity scores to fields
const costTransform = new CreateCostTransform({
// Every field defaults to cost 1
estimatedBytes: ({ node, depth }) => {
if (node.name === 'posts') return 1000; // heavy relation
if (node.name === 'comments') return 500;
return 10;
}
});
// Reject queries exceeding complexity threshold
const apolloConfig = {
plugins: [
{
async didEncounterErrors({ errors, queryPlan }) {
errors.forEach(err => {
if (err.extensions?.code === 'QUERY_TOO_COMPLEX') {
metrics.increment('graphql.query.too_complex');
}
});
}
}
]
}; Depth Limiting and Alias Abuse
// Query depth — limit nesting levels
const depthLimit = createDepthLimit({
maxDepth: 10,
depthHint: {
'Query.user': 3 // allow deeper under specific fields
}
});
// Alias abuse — same field queried many times with different aliases
// query { a:user b:user c:user ... z:user { name } }
// Resolves the same field 26 times → wasteful
const maxAliases = createAliasLimit({ maxAliases: 10 });
// Persisted Queries — lock queries to a known set
const apolloConfig = {
persistedQueries: {
cache: new RedisKeyValueCache({ client }), // store query hashes
}
};
// Client sends: { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "..." } } }
// Server looks up hash → returns pre-stored query → executes
// Unknown hash → 400 Bad Request. CDN can cache by hash. Automatic Persisted Queries (APQ)
// APQ: client sends hash first, full query only on cache miss
// Step 1: client sends hash only
// { "query": "", "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123" } } }
// Step 2: server has "abc123"? Execute it.
// Step 3: server doesn't have it? Client sends full query → server stores it → executes
// Benefits: CDN can cache by hash, full query rarely travels wire, replay protection Caching Strategies
HTTP caching doesn't work naturally with GraphQL POST requests. Several layered strategies address different cache needs.
@key directives declare entity boundaries.Apollo Client Normalized Cache
// Cache stores each entity once, identified by type + id
// Query: user(id: "123") { name posts { title author { name } } }
// Results in cache:
// User:123 → { __typename: "User", name: "Ada", posts: [Post:1, Post:2] }
// Post:1 → { __typename: "Post", title: "...", author: User:456 }
// User:456 → { __typename: "User", name: "Alan" }
import { ApolloClient, InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
User: {
fields: {
posts: {
merge(existing = [], incoming) { return [...incoming]; }
}
}
}
}
});
const client = new ApolloClient({ cache, link: httpLink });
// Write to cache directly (for optimistic updates):
cache.writeQuery({
query: USER_QUERY,
data: { user: { ...newPost, __typename: 'User' } }
}); Security
GraphQL's flexibility creates attack surface that REST doesn't have. Introspection, query depth, and alias abuse are the primary vectors. Production deployments need layered defenses.
Introspection Exploitation
// Introspection query — enumerates entire schema
query {
__schema {
types { name fields { name type { name } } }
queryType { name }
mutations { name }
}
}
// Attacker workflow: introspection → find sensitive fields → exploit
// Example: __type(name: "User") { fields { name type { name } } }
// → finds "isAdmin", "passwordHash" fields → queries them
// Production mitigations:
// 1. Disable introspection (Apollo Server: introspection: false)
// 2. Require authentication for introspection query
// 3. Strip sensitive fields from schema in production build
// 4. Use graphql-cost-analysis to rate-limit expensive introspection
const server = new ApolloServer({
introspection: process.env.NODE_ENV === 'production' ? false : true,
}); Query Cost Analysis
// Rate-limit by computed query cost before execution
import { createCostLimitRule } from 'graphql-cost-analysis';
const rule = createCostLimitRule({
maxCost: 10000,
variablesCost: {
"first": 1, // each pagination item costs 1
"limit": 1,
},
objectCost: {
"Post": 5, // resolving a Post costs 5
}
});
// Reject if sum(cost) > maxCost → 429 or 400 Timeout Limits
// Timeout per query (prevent runaway resolvers)
const server = new ApolloServer({
formatError: (formattedError) => {
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
formattedError.message = 'An unexpected error occurred';
}
return formattedError;
},
formatResponse: (response) => {
return response;
}
});
// Timeout at HTTP layer:
// nginx: proxy_read_timeout 30s;
// Apollo Server plugins can also abort execution on timeout
// Resolver timeout pattern:
async function resolverWithTimeout(parent, args, context, info) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Resolver timeout')), 5000)
);
return Promise.race([slowOperation(args), timeout]);
} Error Handling
GraphQL responses always return 200 OK (for valid queries) with an
errors array alongside the data object. This is by design — it
allows partial results: some fields can succeed while others fail.
// Partial success — some fields errored, some returned data
{
"data": {
"user": {
"id": "123",
"name": "Ada Lovelace"
},
"posts": null
},
"errors": [
{
"message": "Database connection failed",
"locations": [{ "line": 3, "column": 5 }],
"path": ["posts"],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"stacktrace": [...] // only in development
}
}
]
}
// Error codes (Apollo Server conventions):
// GRAPHQL_PARSE_ERROR — malformed query syntax
// GRAPHQL_VALIDATION_FAILED — query fails schema validation
// UNAUTHENTICATED — missing or invalid auth token
// FORBIDDEN — authenticated but not authorized
// INTERNAL_SERVER_ERROR — resolver threw or unexpected failure
// BAD_USER_INPUT — args fail validation Structured Error Extensions
// Attach structured data to errors for client-side handling
throw new UserInputError("Invalid email format", {
extensions: {
code: 'BAD_USER_INPUT',
field: 'email',
invalidValue: attemptedEmail
}
});
// Client handles based on error structure:
const result = await client.mutate({ mutation: UPDATE_USER, variables: {...} });
if (result.errors?.[0]?.extensions?.code === 'BAD_USER_INPUT') {
const field = result.errors[0].extensions.field;
setFieldError(field, result.errors[0].message);
} Code Generation — Types from Schema
GraphQL schemas are the single source of truth. Code generation tools (Codegen, GraphQL Code Generator) read your schema and generate TypeScript types, React hooks, and typed resolver signatures — eliminating the boilerplate and type drift that come from manually maintaining parallel type definitions.
// codegen.yml — run with: npx graphql-codegen
# schema: ./src/schema/schema.graphql
# generates: src/generated/graphql.ts
overrides:
- generates:
src/generated/:
preset: client
plugins:
- typescript
- typescript-operations
- typed-document-node
when:
include: ./src/**
---
// Generated TypeScript from schema:
export type GetUserQuery = {
user: {
id: string;
name: string;
email: string | null;
posts: Array<{
title: string;
author: { name: string };
}>;
} | null;
};
// Typed React hook (no manual type annotation needed):
const { data, loading, error } = useQuery(GetUserQuery, {
variables: { id: "123", includeEmail: true }
}); Typed Resolver Signatures
// With graphql-codegen, resolvers get type-checked signatures
// Generated: src/generated/resolvers-types.ts
export type ResolverType<TypeName extends keyof ResolversTypes> =
ResolversTypes[TypeName] extends GraphQLScalarType | string | number
? Resolver<ResolversTypes[TypeName]>
: ResolversTypes[TypeName] extends Record<string, unknown>
? Partial<Record<keyof ResolversTypes[TypeName], Resolver>>
: never;
// Resolver must match schema shape — TypeScript enforces field completeness
const resolvers: Resolvers = {
Query: {
user: (_: unknown, { id }: QueryUserArgs, ctx: Context) => {
// id is typed as string, ctx is typed as Context
return ctx.db.users.findById(id);
}
}
}; GraphQL vs REST — When Each Wins
GraphQL Wins
- Multiple resource fetching in one round-trip — UI needs user + posts + comments. REST requires 3–4 requests; GraphQL needs 1.
- Mobile clients optimizing for bandwidth — clients request only the fields they need. No over-fetching of unused data.
- Rapidly evolving product UI — new fields added to UI without backend changes (if schema supports them). No versioned REST endpoints.
- Analytics-heavy clients — every product surface needs different data shapes. GraphQL lets each screen declare exactly what it needs.
- Schema as contract — frontend and backend teams agree on types, iterate independently. Schema is the source of truth.
REST Wins
- Simple, well-defined resource models — CRUD on resources with predictable shapes. No need for a flexible query language.
- HTTP caching infrastructure — GET requests cache at CDN, browser, and proxy layers automatically. GraphQL POST requests don't.
- Bandwidth for simple clients — simple clients (IoT, legacy integrations) benefit from well-understood REST conventions.
- Team familiarity and tooling maturity — REST has 20 years of tooling, patterns, documentation, and developer familiarity.
- Load balancer-friendly — stateless REST requests distribute naturally. GraphQL stateful connections (for subscriptions) complicate scaling.
Hybrid Pattern
Many teams run both: REST for simple, highly-cacheable, public-facing operations; GraphQL for the client-heavy authenticated product surface. The two can coexist with a BFF (Backend for Frontend) pattern — GraphQL gateway in front of REST services.
Production Patterns
Query Batching
// Apollo Client batched link — combines multiple operations into one HTTP request
import { BatchHttpLink } from '@apollo/client';
const batchLink = new BatchHttpLink({
uri: 'http://localhost:4000/graphql',
batchMax: 5, // max operations per batch
batchInterval: 20 // wait up to 20ms to batch
});
// Three queries fired within 20ms are sent as one HTTP POST with:
// [{"query": "...", "variables": {...}}, {"query": "...", "variables": {...}}, ...]
// Server (Apollo Server): parses batch → executes each → returns array of results
// Use case: React renders multiple components in the same tick, each fires a query.
// Without batching: N HTTP requests. With batching: 1 HTTP request. @defer — Streaming Partial Results
// @defer directive — server streams results, non-critical fields arrive later
// Useful when a field is expensive (audit log, related data) but not needed immediately
query {
user(id: "123") {
id
name
email # arrive immediately
posts @defer { # stream in after initial payload
title
}
}
}
// Response stream:
// {"data": {"user": {"id": "123", "name": "Ada", "email": "[email protected]" }}} ← fast
// {"data": {"user": {"posts": [...] }}, "hasNext": false} ← slow
// Apollo Server 4+ supports @defer with incremental delivery (HTTP multipart/mixed) @live — Polling Without Re-fetching
// @live directive — tells server to refresh results on a timer
// Client subscribes to live data without full query re-execution
query @live {
onlineUsers {
id
name
lastSeen
}
}
// Server polls the resolver every 5s (configurable), pushes updated results
// Unlike subscriptions, @live uses simple polling under the hood Tooling Ecosystem
Key Numbers
FAQ
Why does GraphQL have an N+1 problem but REST doesn't?
REST responses are bounded by the resource structure — a /users/123 response has one user. GraphQL lets the client ask for user.posts[0].comments[0].author, chaining across relationships in a single query. A naive resolver executes one DB query per node in the graph, so 100 posts with 10 comments each = 1 + 100 + 1000 = 1,101 database round-trips. REST avoids this because the server controls the response shape and can eagerly JOIN everything. DataLoader solves N+1 by batching requests: collect all requested IDs across a single event-loop tick, fire one batch query, then distribute results to individual resolvers.
Schema stitching vs Apollo Federation — which to use?
Schema stitching (merging two schemas into one at build time) works for small graphs but hits a wall: type conflicts, shared types, and ownership boundaries. Federation (Apollo v2) takes a different approach: each service owns its types and declares which parts of the schema it contributes. The gateway composes at runtime. Federation wins for team autonomy — a User service can evolve its own types without coordinating with an Order service. Schema stitching is simpler for a single team merging two legacy schemas. Don't stitch at the GraphQL layer what you can solve with your data layer (federation, distributed tracing, schema registry). Start simple, move to federation when team boundaries need it.
Can you cache GraphQL responses at the CDN layer?
Unlike REST GET requests which have a natural cache key (URL + query params), GraphQL typically uses POST with a JSON body — HTTP caching infrastructure (CDN, browser cache, transparent proxies) doesn't parse the JSON body to extract cache-relevant parts. Strategies: (1) Persisted Queries — client sends a query hash instead of full query, server has pre-registered maps, CDN can cache by hash. (2) Persisted queries with CDN — store the mapping at the CDN edge, so the full query never leaves the client. (3) @cache directive — Apollo Client 3's normalized cache stores query results by their entity IDs; Apollo Server's @cacheControl directive sets TTLs per field. (4) Full response caching — some setups cache the entire serialized response keyed by the query hash, but invalidation is tricky. The right answer depends on your data freshness requirements.
What's the real performance cost of GraphQL introspection?
Introspection is a recursive query over __Schema that fetches every type, field, directive, and enum value in your schema. On a schema with 500 types and 10,000 fields, an unqualified introspection query can be expensive — it traverses the full type graph. Attackers use introspection for reconnaissance (what's in your API?) before crafting attacks. Mitigations: disable introspection in production (Apollo Server's introspection: false — but tooling like Apollo Sandbox needs it), require an API key for introspection, or limit it to specific roles. For generated clients (Relay, urql), introspection runs once at build time to generate types, not at runtime.
Why do mutations return the full object?
It's a pattern, not a requirement, but it exists for a good reason: optimistic UI updates. When a client sends a mutation like `mutation { createPost(input:{title:"..."}) { id title } }`, it gets back the created object with the fields it asked for. Apollo Client can use that to update the cache immediately (optimistic update) without waiting for a server round-trip. The alternative — returning a boolean or just the ID — forces the client to refetch after mutation. The Payload type pattern (input + Payload) emerged from this: define exactly what fields the client gets back, keep mutation inputs and return types separate, and make it easy for clients to ask for precisely the data they need to update their local state.
DataLoader vs field-level caching — when to use each?
DataLoader solves N+1 by batching database reads within a single request. It has a per-request lifecycle — new DataLoader instance per request, cleared between requests. Field-level caching (Apollo Client normalized cache) solves a different problem: caching across requests, entity normalization by ID, automatic cache updates after mutations. They compose: DataLoader handles server-side batching to your DB, Apollo Client's cache handles client-side entity deduplication and optimistic updates. Using only DataLoader (no client cache) means every component re-fetches on mount. Using only client cache (no DataLoader) means N+1 hits your database 100 times per request. You need both.