This comprehensive guide explains how to design and implement production-ready GraphQL APIs that scale. It balances architecture principles with real-world examples (SDL + JavaScript/TypeScript) across four key areas: schema design, resolver optimization, real-time subscriptions, and caching strategies. By the end, you’ll have actionable patterns to build scalable, maintainable, and performant GraphQL services.
Introduction
GraphQL is a query language and runtime for APIs that provides clients with precise control over the data they request. Unlike REST, which exposes fixed data shapes at each endpoint, GraphQL allows clients to define their data requirements, eliminating over-fetching and under-fetching problems.
Who This Guide Is For
This guide targets intermediate to advanced developers who want to:
- Design production-ready GraphQL schemas that scale across teams
- Implement resolvers with performance in mind (avoiding N+1 queries)
- Build real-time features with subscriptions
- Apply multi-layer caching strategies effectively
What You’ll Learn
By working through this guide, you’ll understand:
- Schema Design: How to structure types, interfaces, and unions for flexibility and maintainability
- Resolvers: Patterns for efficient data fetching, error handling, and context management
- Real-Time Subscriptions: WebSocket architecture, authentication, and scaling techniques
- Caching: Client-side, server-side, and HTTP caching strategies tailored for GraphQL
Core Terms and Abbreviations
- GraphQL: A query language and runtime for APIs with a strong type system.
- SDL (Schema Definition Language): Human-readable syntax for defining GraphQL types and operations.
- Resolver: A function that converts a GraphQL operation into actual data, returning field values.
- N+1 Problem: Performance anti-pattern where fetching N items causes N+1 database queries (one per item plus initial query).
- DataLoader: Open-source library (by Facebook/Meta) that batches and caches data-fetching operations per request.
- Pub/Sub: Publish-subscribe messaging pattern enabling event-driven subscriptions and real-time notifications.
- WebSocket: Persistent bidirectional communication protocol supporting real-time subscriptions.
- Persisted Queries: Technique mapping query hashes to canonical queries for CDN caching and reduced payload sizes.
Schema Design
The GraphQL schema is your API contract. A well-designed schema reduces client confusion, prevents common mistakes, and makes your API evolve gracefully over time.
Type System Fundamentals
The foundation of any GraphQL API is its type system. Use these principles:
- Explicit naming: Use clear, descriptive type names (
User,Post,Comment) that reflect domain concepts - Single responsibility: Keep types focused โ avoid god objects that contain unrelated fields
- Nullable vs Non-Null: Mark fields as non-null (
!) only when they are guaranteed to always have values; this makes your schema more honest
Example: Blog API Schema
Here’s a complete example schema for a blog platform:
# Core entity types
type User {
id: ID!
name: String!
email: String!
createdAt: String!
posts(limit: Int, cursor: String): PostConnection!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
createdAt: String!
comments(limit: Int): [Comment!]!
commentCount: Int!
}
type Comment {
id: ID!
text: String!
author: User!
createdAt: String!
}
# Connection types for cursor-based pagination
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
startCursor: String
}
# Root query type
type Query {
me: User
user(id: ID!): User
posts(limit: Int, cursor: String): PostConnection!
post(id: ID!): Post
}
# Root mutation type
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
deletePost(id: ID!): DeletePayload!
}
# Input types (separate from output types)
input CreatePostInput {
title: String!
content: String!
}
input UpdatePostInput {
title: String
content: String
}
# Payload types for mutations (consistent error handling)
type CreatePostPayload {
post: Post
error: String
success: Boolean!
}
type UpdatePostPayload {
post: Post
error: String
success: Boolean!
}
type DeletePayload {
success: Boolean!
error: String
}
Key Design Principles
Cursor-Based Pagination Over Offset
For lists that grow large, use cursor-based pagination instead of offset. This avoids:
- The “offset skip” problem (data shifting while paginating)
- Inefficient database queries with large offsets
- Confusion about whether items have been skipped
Input Types vs Output Types
Always create separate input types (CreatePostInput) instead of reusing output types. This prevents:
- Accidentally exposing internal server fields to clients
- Confusion about what clients can actually provide
- Breaking changes when you add new server-only fields
Mutation Payloads for Better Error Handling
Return structured payloads (CreatePostPayload) from mutations instead of just the entity. This allows:
- Non-null mutation fields (mutations never throw errors at the transport level)
- Detailed error messages alongside the successful result
- Better developer experience on the client
Designing Scalable & Maintainable Schemas
- Deprecation over deletion: Use the
@deprecateddirective to mark old fields obsolete, giving clients time to migrate - Extend, never break: Add new fields freely, but avoid changing existing field types or arguments
- Document your schema: Add descriptions to types and fields so introspection tools display helpful information
- Group related fields: Organize queries, mutations, and subscriptions logically to reflect your domain
Naming Conventions & Organization
- Fields:
camelCase(e.g.,firstName,userCount) - Types:
PascalCase(e.g.,UserProfile,BlogPost) - Enums:
UPPER_CASE(e.g.,POST_STATUS) - Interfaces and Unions: Clear, descriptive names reflecting the contract
Relationships, Interfaces, and Unions
Use interfaces to share fields across multiple types:
interface Node {
id: ID!
createdAt: String!
}
type User implements Node {
id: ID!
createdAt: String!
name: String!
}
type Post implements Node {
id: ID!
createdAt: String!
title: String!
}
Use unions when types share no common fields but should be returned interchangeably:
union SearchResult = Post | User | Comment
type Query {
search(query: String!): [SearchResult!]!
}
When implementing relationships (e.g., Post.author: User), rely on resolvers to fetch related data. This separation allows:
- Efficient batch-loading via DataLoader
- Clear data fetching logic
- Easy caching and optimization
Input Validation & Constraints
- Inputs cannot contain circular references โ they must represent acyclic data structures
- Keep inputs minimal โ only include fields clients actually need to provide
- Validate inputs server-side, not client-side (clients can be bypassed)
- Document constraints in descriptions (e.g., “Must be 1-255 characters”)
Practical Design Tips
- Start with client needs: Design your schema around the queries clients need to make
- Query common cases first: Optimize the schema for the 80% use case
- Keep it backward compatible: Add new fields freely, but never remove or change existing ones
- Version explicitly: If you must break compatibility, version the schema (e.g.,
v2_Query) - Document everything: Use descriptions on types and fields for discoverability
Resolvers
Resolvers are functions that translate GraphQL operations into actual data. Well-designed resolvers are performant, maintainable, and handle errors gracefully.
Understanding Resolver Execution
A resolver has this signature in GraphQL libraries like Apollo Server:
resolver(parent, args, context, info) -> value
- parent: The return value of the parent field’s resolver. For root Query fields, this is usually null or undefined.
- args: Arguments passed to this field (e.g.,
id,limit). Automatically validated against the schema. - context: Request-scoped data shared across all resolvers in a single GraphQL request (authentication, database connections, loaders).
- info: Metadata about the query (rarely needed for business logic).
Resolver Execution Order
GraphQL resolvers execute in a breadth-first order by default:
query {
posts(limit: 2) { # Resolver 1: Query.posts executes
id # Resolver 2: Post.id executes (for each post)
title # Resolver 3: Post.title executes (for each post)
author { # Resolver 4: Post.author executes (for each post)
name # Resolver 5: User.name executes (for each author)
}
}
}
Example: Building a Resolver Map
Here’s how to structure resolvers for the blog schema:
const resolvers = {
Query: {
// Root query resolver
posts: async (parent, { limit = 10, cursor }, { db }) => {
const posts = await db.posts.findPaginated({ limit, cursor });
return posts;
},
post: async (parent, { id }, { loaders }) => {
// Use DataLoader to prevent N+1 when called multiple times
return loaders.post.load(id);
}
},
Post: {
// Field resolver for Post.author
author: async (post, args, { loaders }) => {
// DataLoader batches these calls
return loaders.user.load(post.authorId);
},
// Field resolver for Post.commentCount
commentCount: async (post, args, { db }) => {
return db.comments.countByPostId(post.id);
}
},
User: {
// Field resolver for User.posts
posts: async (user, { limit = 10 }, { db }) => {
return db.posts.findByAuthorId(user.id, { limit });
}
}
};
Preventing the N+1 Problem with DataLoader
The N+1 problem occurs when a query fetches items, then queries the database once per item to fetch related data.
The Problem:
// Without DataLoader - causes N+1 queries
Post.author resolver:
for (const post of posts) {
post.author = await db.users.findById(post.authorId); // 1 query per post!
}
// Total: 1 query to fetch posts + N queries to fetch authors
The Solution with DataLoader:
// create-loaders.js
const DataLoader = require('dataloader');
function createLoaders(db) {
return {
user: new DataLoader(async (userIds) => {
// Fetch all users in ONE query
const users = await db.users.find({
id: { $in: userIds }
});
// Return in same order as requested IDs
return userIds.map(id => users.find(u => u.id === id));
}),
postsByAuthor: new DataLoader(async (authorIds) => {
const posts = await db.posts.find({
authorId: { $in: authorIds }
});
// Group by authorId
return authorIds.map(id =>
posts.filter(p => p.authorId === id)
);
})
};
}
// Attach loaders to context
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const user = await authenticate(req);
return {
user,
db,
loaders: createLoaders(db)
};
}
});
// In resolver - uses batched loader
Post: {
author: (post, args, { loaders }) =>
loaders.user.load(post.authorId) // Batched automatically!
}
How DataLoader Works:
- Collects all requests to
.load()during the current tick - Deduplicates them automatically
- Batches them into a single call to the batch function
- Returns results in the correct order
Error Handling in Resolvers
Always handle errors gracefully and provide meaningful messages to clients:
const { ApolloError } = require('apollo-server');
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
try {
if (!id.match(/^\d+$/)) {
throw new ApolloError(
'Invalid user ID',
'INVALID_ID',
{ userId: id }
);
}
const user = await context.db.users.findById(id);
if (!user) {
throw new ApolloError(
'User not found',
'USER_NOT_FOUND',
{ userId: id }
);
}
return user;
} catch (err) {
// Log for debugging
console.error('Error fetching user:', err);
// Re-throw Apollo errors as-is
if (err.extensions?.code) throw err;
// Wrap unexpected errors
throw new ApolloError(
'Internal server error',
'INTERNAL_SERVER_ERROR'
);
}
}
}
};
Best practices for error handling:
- Use error codes (
USER_NOT_FOUND,INVALID_INPUT) for client handling - Include minimal information in error messages (no internal details)
- Log full errors server-side for debugging
- Distinguish validation errors from runtime errors
Context: Sharing Data Across Resolvers
The context object is your way to share request-scoped data across all resolvers. Good candidates for context:
- Authentication: Current user information from JWT or session
- Loaders: DataLoader instances for batching
- Database connections: Connection pools (avoid creating new connections per request)
- Services: Business logic services
- Request ID: For distributed tracing
context: async ({ req }) => {
const user = await authenticate(req); // throws if invalid
return {
user, // Auth info
loaders: createLoaders(db), // DataLoaders
db, // Database connection
services: { // Business services
emailService: new EmailService(),
analyticsService: new AnalyticsService()
},
requestId: req.headers['x-request-id']
};
}
Thin Resolvers, Fat Services
A common pattern is to keep resolvers thin and push logic into service classes:
// resolver (thin)
Mutation: {
createPost: async (parent, { input }, { services, user }) => {
return services.postService.create(input, user.id);
}
}
// service (fat, contains business logic)
class PostService {
constructor(db, emailService) {
this.db = db;
this.emailService = emailService;
}
async create(input, authorId) {
// Validation
if (input.title.length < 1) {
throw new Error('Title required');
}
// Business logic
const post = {
...input,
authorId,
createdAt: new Date()
};
// Persistence
const saved = await this.db.posts.insert(post);
// Side effects
await this.emailService.notifyFollowers(authorId, saved);
return saved;
}
}
Subscriptions (Real-Time)
Subscriptions enable real-time, bidirectional communication. Unlike queries and mutations (request-response), subscriptions maintain open connections and push updates to clients as events occur.
How Subscriptions Work
- Client establishes a WebSocket connection with the server
- Client sends a subscription query
- Server maintains the connection and sends updates when events occur
- Connection stays open until client disconnects or unsubscribes
Schema Definition
Define subscriptions in your SDL just like queries and mutations:
type Subscription {
# Fires when a new post is created
postCreated: Post!
# Fires when a specific user gets a new notification
userNotified(userId: ID!): Notification!
# Real-time updates for a post's comment count
postUpdated(id: ID!): Post!
}
Implementation with Pub/Sub
Most GraphQL servers use a Pub/Sub (Publish-Subscribe) pattern:
const { PubSub } = require('apollo-server');
const pubsub = new PubSub();
// Define which events to listen for
const POST_CREATED = 'POST_CREATED';
const USER_NOTIFIED = 'USER_NOTIFIED';
const resolvers = {
Subscription: {
// Simple subscription - fires for all clients
postCreated: {
subscribe: (parent, args, { pubsub }) => {
return pubsub.asyncIterator([POST_CREATED]);
}
},
// Filtered subscription - only fires for specific user
userNotified: {
subscribe: (parent, { userId }, { pubsub }) => {
const channel = `USER_NOTIFIED_${userId}`;
return pubsub.asyncIterator([channel]);
}
}
}
};
// When creating a post, publish an event
Mutation: {
createPost: async (parent, { input }, { services, pubsub, user }) => {
const post = await services.postService.create(input, user.id);
// Broadcast to all subscribed clients
pubsub.publish(POST_CREATED, {
postCreated: post
});
return { success: true, post };
}
}
// When notifying a user, publish scoped event
async function notifyUser(userId, notification) {
const channel = `USER_NOTIFIED_${userId}`;
pubsub.publish(channel, {
userNotified: notification
});
}
Filtering with withFilter
For subscriptions with multiple users, use withFilter to avoid broadcasting to everyone:
const { withFilter } = require('apollo-server');
const resolvers = {
Subscription: {
postUpdated: {
subscribe: withFilter(
(parent, args, { pubsub }) => {
// Subscribe to all post updates
return pubsub.asyncIterator(['POST_UPDATED']);
},
(payload, variables) => {
// Only send to clients subscribed to this specific post
return payload.postUpdated.id === variables.id;
}
),
resolve: (payload) => payload.postUpdated
}
}
};
// Publish
pubsub.publish('POST_UPDATED', {
postUpdated: updatedPost
});
Authentication in WebSocket Subscriptions
Authenticate the WebSocket connection at handshake time:
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req, connectionParams }) => {
// For HTTP requests
if (req) {
const token = req.headers.authorization;
const user = await authenticateToken(token);
return { user, loaders: createLoaders(db) };
}
// For WebSocket subscriptions
if (connectionParams) {
const token = connectionParams.authorization;
const user = await authenticateToken(token);
if (!user) {
throw new Error('Authentication required');
}
return { user, loaders: createLoaders(db) };
}
}
});
Client connects with authentication:
// Apollo Client example
const wsLink = new WebSocketLink({
uri: `wss://api.example.com/graphql`,
options: {
reconnect: true,
connectionParams: {
authorization: localStorage.getItem('token')
}
}
});
Scaling Subscriptions: Using Redis Pub/Sub
By default, PubSub is in-memory, which only works for single-server deployments. For multiple server instances, use a durable Pub/Sub system like Redis:
const { RedisPubSub } = require('graphql-redis-subscriptions');
const redis = require('redis');
const pubsub = new RedisPubSub({
publisher: redis.createClient({ url: process.env.REDIS_URL }),
subscriber: redis.createClient({ url: process.env.REDIS_URL })
});
const server = new ApolloServer({
typeDefs,
resolvers: {
Subscription: {
postCreated: {
subscribe: (parent, args, { pubsub }) => {
return pubsub.asyncIterator(['POST_CREATED']);
}
}
}
}
});
With Redis:
- Multiple server instances can publish/subscribe to the same channels
- Events persist briefly, avoiding race conditions
- Scaling is much simpler
Handling Disconnections & Cleanup
Subscriptions should clean up when clients disconnect:
const resolvers = {
Subscription: {
userActivity: {
subscribe: async function* (parent, args, { user, pubsub }) {
const channel = `USER_${user.id}_ACTIVITY`;
// Mark user as active
await db.users.updateStatus(user.id, 'ONLINE');
try {
// Yield events
for await (const event of pubsub.asyncIterator([channel])) {
yield event;
}
} finally {
// Cleanup on disconnect
await db.users.updateStatus(user.id, 'OFFLINE');
}
}
}
}
};
Best Practices for Subscriptions
- Scope subscriptions carefully: Use user IDs or resource IDs to prevent leaking data
- Implement rate limiting: Prevent subscription spam (e.g., max 10 subscriptions per client)
- Set connection timeouts: Close idle connections after 60 seconds
- Monitor memory: Each subscription holds a connection; unbounded subscriptions can exhaust memory
- Use backpressure: Implement queuing if your server can’t keep up with event publishing
- Document subscription events: Clear documentation helps clients know when events fire
WebSocket Implementation Considerations
- Authentication: Authenticate the connection during the WebSocket handshake and attach the user to
context. - Filtering: Use
withFilteror server-side predicates to send only relevant events to clients. - Back-pressure & Limits: Implement rate limits and connection timeouts.
Scaling Subscriptions
- Use durable Pub/Sub (Redis, NATS, Kafka) instead of in-memory PubSub so multiple server instances can dispatch events.
Architecture example (text graph):
Client -> WebSocket -> Subscription Gateway -> Backend Servers -> PubSub (Redis/Kafka)
^
-> Microservices publish events to topics
Security & Auth in Subscriptions
- Verify the token at connection time and re-check on critical operations.
- Apply authorization at publish and subscribe time.
Caching Strategies
Caching is critical for GraphQL performance, but harder than REST because queries are arbitrary. A layered approach combining client, server, and HTTP caching provides the best results.
Understanding the Caching Problem in GraphQL
Unlike REST (where endpoints are fixed), GraphQL clients can request any combination of fields. This makes full-response caching difficult. Solutions:
- Client-side caching: Cache data normalized by type + ID
- Field-level caching: Cache individual resolver results
- Query response caching: Cache complete responses for persisted queries
- HTTP caching: Use CDN for known queries with
Cache-Controlheaders
Client-Side Caching (Apollo Client)
Apollo Client maintains a normalized cache that stores entities by __typename and id:
// Apollo Client cache structure (conceptual)
{
"User:1": {
__typename: "User",
id: "1",
name: "Alice",
email: "[email protected]"
},
"Post:42": {
__typename: "Post",
id: "42",
title: "GraphQL Tips",
authorId: "1" // Reference to User:1
}
}
Cache Policies:
import { useQuery } from '@apollo/client';
// cache-first: Use cache if available, otherwise fetch
// Best for: Static content, user profiles
const { data } = useQuery(GET_USER, {
fetchPolicy: 'cache-first'
});
// network-only: Always fetch from server, update cache
// Best for: Real-time data, current user info
const { data } = useQuery(GET_NOTIFICATIONS, {
fetchPolicy: 'network-only'
});
// cache-and-network: Use cache immediately, fetch in background
// Best for: Balance between freshness and UX
const { data } = useQuery(GET_POSTS, {
fetchPolicy: 'cache-and-network'
});
// no-cache: Don't use cache at all
// Best for: One-time queries, sensitive data
const { data } = useQuery(GET_PASSWORD_RESET, {
fetchPolicy: 'no-cache'
});
Cache Updates:
// Manually update cache after mutation
const [createPost] = useMutation(CREATE_POST, {
update(cache, { data: { createPost } }) {
// Get existing posts from cache
const { posts } = cache.readQuery({ query: GET_POSTS });
// Update cache with new post
cache.writeQuery({
query: GET_POSTS,
data: {
posts: [createPost, ...posts]
}
});
}
});
Server-Side Caching: Multiple Strategies
1. Field-Level Caching (Resolver Caching)
Cache individual resolver results keyed by field and arguments:
const redis = require('redis');
const client = redis.createClient();
const resolvers = {
User: {
posts: async (user, { limit = 10 }, { db, cache }) => {
const cacheKey = `user:${user.id}:posts:${limit}`;
// Try cache first
const cached = await cache.get(cacheKey);
if (cached) return JSON.parse(cached);
// Fetch and cache
const posts = await db.posts.findByAuthorId(user.id, { limit });
await cache.set(cacheKey, JSON.stringify(posts), 'EX', 300); // 5 min TTL
return posts;
}
},
Post: {
commentCount: async (post, args, { db, cache }) => {
const cacheKey = `post:${post.id}:commentCount`;
const cached = await cache.get(cacheKey);
if (cached) return parseInt(cached);
const count = await db.comments.countByPostId(post.id);
await cache.set(cacheKey, count, 'EX', 600); // 10 min TTL
return count;
}
}
};
2. Response-Level Caching (Query Result Caching)
Cache the complete GraphQL response for queries (especially for persisted queries):
const crypto = require('crypto');
function createQueryHash(query, variables) {
return crypto
.createHash('md5')
.update(query + JSON.stringify(variables))
.digest('hex');
}
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: {
didResolveOperation: async (requestContext) => {
const { request, cache } = requestContext;
// Only cache GET queries (safe queries)
if (request.operationName !== 'query') return;
const queryHash = createQueryHash(
request.query,
request.variables
);
// Store hash in context for use in willSendResponse
requestContext.queryHash = queryHash;
},
willSendResponse: async (requestContext) => {
const { queryHash, response, cache } = requestContext;
if (!queryHash || response.errors) return;
// Cache successful response for 60 seconds
const cacheKey = `graphql:${queryHash}`;
await cache.set(
cacheKey,
JSON.stringify(response),
'EX',
60
);
}
}
});
3. Dataloader Caching (Request-Scoped)
As discussed earlier, DataLoader automatically caches batched requests within a single GraphQL request:
// Per-request deduplication and batching
const userLoader = new DataLoader(async (userIds) => {
// This function runs ONCE per batch, even if userIds contains duplicates
const users = await db.users.find({ id: { $in: userIds } });
return userIds.map(id => users.find(u => u.id === id));
});
// Multiple resolvers can use the same loader
Post.author: (post, args, { loaders }) => loaders.user.load(post.authorId);
Comment.author: (comment, args, { loaders }) => loaders.user.load(comment.authorId);
// โ Only ONE database query for all user IDs
Cache Invalidation Patterns
The hardest part of caching is invalidating stale data. Here are proven patterns:
Pattern 1: Event-Driven Invalidation
Publish invalidation events when data changes:
Mutation: {
updatePost: async (parent, { id, input }, { db, cache, pubsub }) => {
const post = await db.posts.update(id, input);
// Invalidate specific keys
await cache.del(`post:${id}:*`); // Wildcard delete
await cache.del(`posts:*`); // Invalidate all posts lists
// Publish event for other servers
pubsub.publish('CACHE_INVALIDATE', {
keys: [`post:${id}`]
});
return post;
}
}
Pattern 2: Tag-Based Invalidation
Tag related cache entries for bulk invalidation:
// Tag-aware cache wrapper
class TaggedCache {
constructor(redis) {
this.redis = redis;
this.tags = new Map(); // tag -> cache keys
}
async set(key, value, tags = []) {
await this.redis.set(key, value);
for (const tag of tags) {
const tagKey = `tag:${tag}`;
await this.redis.sadd(tagKey, key);
}
}
async invalidateTag(tag) {
const tagKey = `tag:${tag}`;
const keys = await this.redis.smembers(tagKey);
for (const key of keys) {
await this.redis.del(key);
}
await this.redis.del(tagKey);
}
}
// Usage in resolvers
User.posts: async (user, args, { cache }) => {
const cacheKey = `user:${user.id}:posts`;
const posts = await fetchPosts();
await cache.set(cacheKey, posts, [
`user:${user.id}`, // Tag for user-related data
'posts' // Tag for all posts
]);
return posts;
}
// Invalidate all data for a user
Mutation.deleteUser: async (parent, { id }, { cache }) => {
await cache.invalidateTag(`user:${id}`);
return { success: true };
}
Pattern 3: Time-Based TTLs
For non-critical data, simple TTLs are often sufficient:
// Short TTL for frequently changing data
await cache.set(key, value, 'EX', 30); // 30 seconds
// Longer TTL for stable data
await cache.set(key, value, 'EX', 3600); // 1 hour
// No expiry for never-changing reference data
await cache.set(key, value);
Persisted Queries
Persisted queries map query hashes to canonical queries, enabling CDN caching:
How it works:
- Client registers queries with server beforehand
- Client sends query ID instead of full query text
- Server looks up query from ID
- Server can cache the response more aggressively
- CDN sees consistent cache keys
Setup:
const persistedQueries = new Map([
['abc123', `
query GetPosts {
posts {
id
title
author { name }
}
}
`],
['def456', `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`]
]);
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: {
didResolveOperation: async (ctx) => {
const { request } = ctx;
// If client sent query ID, look it up
if (request.extensions?.persistedQuery?.sha256hash) {
const hash = request.extensions.persistedQuery.sha256hash;
request.query = persistedQueries.get(hash);
if (!request.query) {
throw new Error('Query not found');
}
}
}
}
});
CDN Integration:
# With persisted query, cache key is consistent
GET /graphql?id=abc123&operationName=GetPosts
# โ Cache-Control: public, max-age=3600
# โ All clients get same cached response
# Without persisted query, cache is unpredictable
GET /graphql with POST body containing full query
# โ Different formatting = different cache key
# โ Poor cache hit rate
HTTP Caching Headers
Use Cache-Control headers for publicly cacheable queries:
const server = new ApolloServer({
plugins: {
willSendResponse: async (ctx) => {
const { response } = ctx;
if (response.errors) {
// Don't cache errors
response.headers.set('Cache-Control', 'no-cache, no-store');
} else {
// Cache publicly-safe queries
response.headers.set(
'Cache-Control',
'public, max-age=3600' // 1 hour
);
}
}
}
});
Multi-Layer Caching Strategy
Combine all layers for optimal performance:
Client Query
โ
[1] Apollo Client Cache (normalized by entity)
โ Hit: Return immediately
โ Miss: Continue to server
โ
Server GraphQL Request
โ
[2] Response Cache (persisted query hash)
โ Hit: Return complete response
โ Miss: Continue to resolvers
โ
[3] Field-Level Cache (per resolver)
โ Hit: Return cached value
โ Miss: Execute resolver
โ
[4] DataLoader Batch Cache (per request)
โ Deduplicates and batches database calls
โ
Database Query
Example configuration:
// For user profile (stable, frequently requested)
TTL: 1 hour | Cache-Control: public, max-age=3600
// For post list (semi-stable)
TTL: 5 minutes | Cache-Control: private, max-age=300
// For real-time notifications
TTL: none | Cache-Control: no-cache, must-revalidate
// For user-specific data
TTL: 30 seconds | Cache-Control: private, max-age=30
Common Pitfalls & Best Practices
Anti-Patterns to Avoid
Returning huge nested graphs by default
query {
user(id: 1) {
posts { # Returns all posts
comments { # Returns all comments per post
author { # Returns author for each comment
posts { # Returns all posts for each author...
...
}
}
}
}
}
}
Solution: Use query complexity analysis and depth limiting.
Heavy logic in resolvers
// Bad
Post: {
author: async (post) => {
// Complex business logic mixed with data fetching
const user = await db.users.findById(post.authorId);
user.reputation = calculateReputation(user);
user.badges = await fetchBadges(user.id);
// ... more complex logic
return user;
}
}
Solution: Extract to services.
// Good
Post: {
author: (post, args, { loaders }) =>
loaders.user.load(post.authorId) // Simple!
}
// Logic lives in service
class UserService {
async getEnrichedUser(userId) {
const user = await db.users.findById(userId);
user.reputation = calculateReputation(user);
user.badges = await fetchBadges(userId);
return user;
}
}
Best Practices
- Always paginate list fields: Use cursor-based pagination for any list that could grow
- Validate everything: Validate inputs server-side; never trust clients
- Use DataLoader: Prevent N+1 queries with batching
- Keep schemas backward compatible: Deprecate, never delete
- Rate limit expensive operations: Queries with high complexity should be throttled
- Monitor query performance: Track slow queries and resolver times
- Document your schema: Use SDL descriptions extensively
- Test edge cases: Empty lists, null values, deeply nested queries
- Security first: Implement authentication, authorization, and input validation
GraphQL vs Alternatives: When to Use What
GraphQL: Best For
Pros:
- Client-driven queries: Clients request exactly what they need; no over-fetching of unused fields
- Strong typing: SDL provides excellent discoverability and enables tooling (IDE support, schema introspection)
- Single endpoint: Simpler deployment and routing logic
- Real-time subscriptions: Native support for WebSocket subscriptions
- Rapid frontend development: Frontend developers write queries matching their UI structure
- API evolution: Deprecate fields gradually without breaking changes
Cons:
- Caching complexity: Arbitrary queries make traditional HTTP caching difficult (need persisted queries)
- Authorization complexity: Field-level auth requires careful design
- N+1 query risk: Easy to write queries that cause performance problems
- Query complexity: Clients can write expensive queries (arbitrary nesting)
- Learning curve: Both client and server side have more concepts to learn
- Monitoring difficulty: Different queries = harder to track performance
When to choose GraphQL:
- Building single-page applications with complex data requirements
- Supporting multiple clients with different data needs (web, mobile, IoT)
- Need rapid iteration on API surface
- Planning real-time features (subscriptions)
- Building data platforms with heterogeneous data sources
REST: Best For
Pros:
- Simplicity: Fewer concepts to learn; standard HTTP semantics
- Caching: Standard HTTP caching (ETags, Cache-Control) works out-of-the-box
- CDN-friendly: Easy to cache full responses at CDN level
- Monitoring: Clear endpoint โ request mapping; easier debugging
- Browser testing: Can test directly in browser address bar
Cons:
- Over-fetching: Often return more data than needed
- Under-fetching: May require multiple requests to get related data
- Versioning: Managing v1/v2/v3 endpoints becomes painful
- Limited introspection: No built-in schema discovery
When to choose REST:
- Building simple, CRUD-focused APIs
- APIs with highly cacheable responses
- Internal services for microservices architectures
- Legacy systems with REST expectations
gRPC: Best For
Pros:
- Performance: Binary protocol (protobuf) much faster than JSON
- Type safety: Strict schema with code generation
- Streaming: Native bidirectional streaming
- Multiplexing: HTTP/2 connection reuse
Cons:
- Browser incompatibility: Requires proxy/gateway for browser clients
- Learning curve: Steep; different paradigm
- Debugging difficulty: Binary protocol hard to inspect
When to choose gRPC:
- Internal microservices communication
- High-performance systems requiring low latency
- Heavy streaming workloads
- When you have a purely backend ecosystem
Further Resources
Official Documentation:
- GraphQL Official Site โ Specification and learning resources
- Apollo Server Documentation โ Production GraphQL server framework
Key Libraries & Tools:
- DataLoader โ Batch loading to prevent N+1 queries
- graphql-depth-limit โ Limit query depth
- graphql-query-complexity โ Analyze query complexity
Learning & Tutorials:
Real-World Examples:
- Star Wars GraphQL API โ Educational example
- GitHub GraphQL API โ Production API reference
Conclusion
Building scalable GraphQL APIs requires balancing four key areas:
- Schema Design: Start with client needs; use clear types, relationships, and pagination
- Resolvers: Keep them thin; use DataLoader for batching; implement proper error handling
- Subscriptions: Use durable Pub/Sub systems (Redis) for multi-instance deployments; always authenticate WebSocket connections
- Caching: Layer your caches (client, field-level, query-level, HTTP) and implement robust invalidation
Key Takeaways:
- Invest upfront in schema design โ changes become expensive later
- Always use DataLoader to prevent N+1 queries
- Implement authentication and authorization at every level
- Monitor query performance and complexity
- Use persisted queries for better caching and smaller payloads
- Document your schema extensively
Next Steps:
- Start a new GraphQL project with Apollo Server
- Set up DataLoader for your database layer
- Implement Redis-backed Pub/Sub for subscriptions
- Add comprehensive error handling and logging
- Set up query complexity analysis and rate limiting
The effort invested in these fundamentals will pay dividends as your API grows and scales across teams and platforms.
Comments