- GraphQL uses a single endpoint and SDL schema to give clients full control over response shape, solving over/under-fetching.
- Apollo Server resolvers receive (parent, args, context, info) — put auth user, DataLoaders, and data sources in context.
- DataLoader is mandatory for production GraphQL: it batches N+1 resolver calls into single database queries per request tick.
- Apollo Client's InMemoryCache normalizes by __typename + id, auto-updating all components when mutations return known objects.
- Use cursor-based pagination (Relay spec) for stable, scalable pagination that handles concurrent data changes.
- JWT auth lives in the HTTP layer; the context function decodes it before resolvers run.
- Persisted queries + GET requests enable CDN caching for GraphQL; Redis handles server-side resolver cache.
- GraphQL errors always return HTTP 200 — check the errors array, not the status code.
What Is GraphQL?
GraphQL is a query language for APIs and a server-side runtime for executing those queries using a type system you define for your data. Developed at Facebook in 2012 and open-sourced in 2015, it solves fundamental problems with REST: over-fetching too much data, under-fetching too little (requiring multiple round trips), and rigid response shapes that do not adapt to diverse client needs.
At its core, GraphQL is schema-first. You define your entire data model in the Schema Definition Language (SDL) — types, queries, mutations, and subscriptions — and that schema becomes both documentation and a contract between your frontend and backend. Every GraphQL API exposes a single endpoint (typically /graphql) that accepts queries, mutations, or subscriptions as POST requests.
The key insight is that the client drives the response shape. Instead of a REST endpoint returning a fixed JSON structure, a GraphQL client sends a query describing exactly which fields it needs. The server resolves only those fields and returns precisely that shape — nothing more, nothing less.
Single Endpoint vs Multiple REST Endpoints
# REST: multiple endpoints, fixed shapes
GET /api/users/42 # Returns ALL user fields (over-fetching)
GET /api/users/42/posts # Separate round trip (under-fetching)
GET /api/users/42/followers # Yet another round trip
# GraphQL: one endpoint, client-defined shape
POST /graphql
{
"query": "query { user(id: \"42\") { name avatar posts(limit: 3) { title } followersCount } }"
}
# Returns exactly: name, avatar, 3 post titles, follower count -- nothing elseSchema Definition Language (SDL)
The Schema Definition Language is GraphQL's type system syntax. Every GraphQL API starts with a schema that defines all available types, queries, mutations, and subscriptions. The schema is the single source of truth for your API's capabilities.
SDL supports scalar types (Int, Float, String, Boolean, ID), object types, enum types, union types, interface types, and input types. Non-null fields are marked with !, and lists use [] notation. Understanding SDL thoroughly is the foundation of all GraphQL work.
Complete Schema Example with All SDL Features
# Custom scalars
scalar DateTime
scalar JSON
# Enum types
enum Role { ADMIN EDITOR VIEWER }
enum PostStatus { DRAFT PUBLISHED ARCHIVED }
# Interface
interface Node { id: ID! }
# Object types
type User implements Node {
id: ID!
name: String! # Non-null: field always present
email: String!
avatar: String # Nullable: may be null
role: Role!
posts(limit: Int = 10, offset: Int = 0): [Post!]!
followersCount: Int!
createdAt: DateTime!
}
type Post implements Node {
id: ID!
title: String!
slug: String!
body: String!
status: PostStatus!
author: User! # Resolved field -- triggers resolver
tags: [String!]! # Non-null list of non-null strings
comments: [Comment!]!
publishedAt: DateTime # Nullable DateTime
updatedAt: DateTime!
}
type Comment implements Node {
id: ID!
body: String!
author: User!
post: Post!
createdAt: DateTime!
}
# Input types (mutation arguments only)
input CreatePostInput {
title: String!
body: String!
tags: [String!]
status: PostStatus = DRAFT # Default value
}
input UpdatePostInput {
title: String # All optional for partial update
body: String
tags: [String!]
status: PostStatus
}
input PostFilter {
status: PostStatus
authorId: ID
tag: String
search: String
}
# Root operation types
type Query {
user(id: ID!): User
users(limit: Int = 20, offset: Int = 0): [User!]!
post(id: ID, slug: String): Post
posts(filter: PostFilter, limit: Int = 20, offset: Int = 0): [Post!]!
postsConnection(filter: PostFilter, first: Int, after: String): PostConnection!
me: User
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
addComment(postId: ID!, body: String!): Comment!
}
type Subscription {
commentAdded(postId: ID!): Comment!
postPublished: Post!
}
# Relay-style cursor pagination types
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}Apollo Server Setup & Resolvers
Apollo Server is the most popular GraphQL server for Node.js. It handles HTTP transport, query parsing and validation against your schema, resolver execution, error formatting, and introspection. Apollo Server 4 can be used as standalone or as middleware with Express, Fastify, or any Node HTTP framework.
Resolvers are functions that return the data for each field in your schema. They receive four arguments: parent (the resolved value from the parent field), args (the field's arguments from the query), context (shared request context — auth user, data sources, loaders), and info (field path, schema, directives). The N+1 problem — where fetching a list then individually fetching related records causes N+1 queries — is solved with DataLoader batching.
Apollo Server 4 with Express, Resolvers & DataLoader
// server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import DataLoader from 'dataloader';
import { readFileSync } from 'fs';
const typeDefs = readFileSync('./schema.graphql', 'utf-8');
// DataLoader factory -- create fresh loaders per request
function createLoaders(db: Database) {
return {
// Batches N individual user.load(id) calls into:
// SELECT * FROM users WHERE id IN ($1, $2, ...$N)
userById: new DataLoader<string, User>(async (ids) => {
const users = await db.users.findByIds([...ids]);
const map = new Map(users.map((u: User) => [u.id, u]));
// Return in SAME ORDER as input ids (DataLoader requirement)
return ids.map(id => map.get(id) ?? new Error('User not found: ' + id));
}),
commentsByPostId: new DataLoader<string, Comment[]>(async (postIds) => {
const comments = await db.comments.findByPostIds([...postIds]);
const grouped = new Map<string, Comment[]>();
comments.forEach((c: Comment) => {
if (!grouped.has(c.postId)) grouped.set(c.postId, []);
grouped.get(c.postId)!.push(c);
});
return postIds.map(id => grouped.get(id) ?? []);
}),
};
}
const resolvers = {
Query: {
user: (_: unknown, { id }: { id: string }, { db }: Context) =>
db.users.findById(id),
posts: (_: unknown, { filter, limit, offset }: any, { db }: Context) =>
db.posts.findMany({ filter, limit, offset }),
postsConnection: async (_: unknown, { filter, first = 10, after }: any, { db }: Context) => {
const afterId = after ? Buffer.from(after, 'base64').toString() : null;
const rows = await db.posts.findMany({
where: { ...filter, ...(afterId ? { id: { gt: afterId } } : {}) },
take: first + 1,
orderBy: { createdAt: 'desc' },
});
const hasNextPage = rows.length > first;
const nodes = rows.slice(0, first);
const edges = nodes.map((node: any) => ({
node,
cursor: Buffer.from(node.id).toString('base64'),
}));
return {
edges,
totalCount: await db.posts.count(filter),
pageInfo: {
hasNextPage,
hasPreviousPage: !!afterId,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
},
me: (_: unknown, __: unknown, { user }: Context) => user ?? null,
},
Mutation: {
createPost: (_: unknown, { input }: any, { user, db }: Context) => {
if (!user) throw new AuthenticationError('Must be logged in');
return db.posts.create({ ...input, authorId: user.id });
},
updatePost: async (_: unknown, { id, input }: any, { user, db }: Context) => {
const post = await db.posts.findById(id);
if (!post) throw new NotFoundError('Post');
if (post.authorId !== user?.id) throw new ForbiddenError('Not your post');
return db.posts.update(id, input);
},
deletePost: async (_: unknown, { id }: any, { user, db }: Context) => {
const post = await db.posts.findById(id);
if (post.authorId !== user?.id) throw new ForbiddenError('Not your post');
await db.posts.delete(id);
return true;
},
},
// Field resolvers -- use loaders to prevent N+1
Post: {
author: (post: any, _: unknown, { loaders }: Context) =>
loaders.userById.load(post.authorId),
comments: (post: any, _: unknown, { loaders }: Context) =>
loaders.commentsByPostId.load(post.id),
},
User: {
posts: (user: any, { limit, offset }: any, { db }: Context) =>
db.posts.findByAuthorId(user.id, { limit, offset }),
followersCount: (user: any, _: unknown, { db }: Context) =>
db.follows.countByFolloweeId(user.id),
},
Subscription: {
commentAdded: {
subscribe: (_: unknown, { postId }: any, { pubsub }: Context) =>
pubsub.asyncIterator('COMMENT_ADDED_' + postId),
},
postPublished: {
subscribe: (_: unknown, __: unknown, { pubsub }: Context) =>
pubsub.asyncIterator('POST_PUBLISHED'),
},
},
};
// Bootstrap
const app = express();
const httpServer = http.createServer(app);
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
introspection: process.env.NODE_ENV !== 'production',
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? await verifyJWT(token) : null;
return { user, db, loaders: createLoaders(db), pubsub, redis };
},
}));
await new Promise<void>(r => httpServer.listen({ port: 4000 }, r));
console.log('GraphQL server ready at http://localhost:4000/graphql');Apollo Client with React Hooks
Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. For React, it provides hooks-based API: useQuery for fetching data, useMutation for sending mutations, and useSubscription for real-time updates. Apollo Client also includes a normalized in-memory cache (InMemoryCache) that automatically deduplicates and updates related data across components.
The InMemoryCache normalizes objects by __typename + id. When a mutation returns an updated object with the same ID as a cached object, every component displaying that data automatically re-renders — no manual state sync needed. Cache policies (cache-first, cache-and-network, network-only, no-cache) control when Apollo reads from cache vs. hits the network.
Apollo Client Setup with Auth, Error Handling & WebSocket
// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink, from, split } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
const httpLink = createHttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '/graphql',
});
// Attach JWT from localStorage to every HTTP request
const authLink = setContext((_, { headers }) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
return {
headers: { ...headers, ...(token ? { authorization: 'Bearer ' + token } : {}) },
};
});
// Centralized error handling
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions?.code === 'UNAUTHENTICATED') {
// Could refresh token here and retry with forward(operation)
window.dispatchEvent(new CustomEvent('apollo:unauthenticated'));
}
}
}
if (networkError) console.error('[Network error]', networkError);
});
// WebSocket link for subscriptions (browser only)
const wsLink = typeof window !== 'undefined'
? new GraphQLWsLink(createClient({
url: (process.env.NEXT_PUBLIC_GRAPHQL_URL ?? '/graphql').replace(/^http/, 'ws'),
connectionParams: () => ({ authToken: localStorage.getItem('token') }),
retryAttempts: 5,
}))
: null;
// Route subscriptions -> WS, everything else -> HTTP
const splitLink = wsLink
? split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
wsLink,
from([errorLink, authLink, httpLink])
)
: from([errorLink, authLink, httpLink]);
export const apolloClient = new ApolloClient({
link: splitLink,
cache: new InMemoryCache({
typePolicies: {
Post: { keyFields: ['slug'] }, // Cache Post by slug, not id
Query: {
fields: {
posts: {
keyArgs: ['filter'], // Separate cache per filter combo
merge(existing = [], incoming, { args }) {
const merged = [...existing];
const offset = args?.offset ?? 0;
incoming.forEach((item: any, i: number) => {
merged[offset + i] = item;
});
return merged;
},
},
},
},
},
}),
defaultOptions: {
watchQuery: { fetchPolicy: 'cache-and-network', errorPolicy: 'all' },
query: { fetchPolicy: 'cache-first', errorPolicy: 'all' },
},
});
useQuery, useMutation, useSubscription in React
'use client';
import { useQuery, useMutation, useSubscription, gql } from '@apollo/client';
const GET_POSTS = gql`
query GetPosts($filter: PostFilter, $limit: Int!, $offset: Int!) {
posts(filter: $filter, limit: $limit, offset: $offset) {
id slug title
author { id name avatar }
tags publishedAt
}
}
`;
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) { id slug title status author { id name } }
}
`;
const POST_PUBLISHED = gql`
subscription OnPostPublished {
postPublished { id slug title author { name } }
}
`;
export function PostFeed() {
// --- useQuery: fetch with cache + loading states ---
const { data, loading, error, fetchMore, refetch } = useQuery(GET_POSTS, {
variables: { filter: { status: 'PUBLISHED' }, limit: 10, offset: 0 },
notifyOnNetworkStatusChange: true,
pollInterval: 0, // Set to ms > 0 for polling
});
// --- useMutation: with optimistic update + cache write ---
const [createPost, { loading: creating, error: mutError }] = useMutation(CREATE_POST, {
optimisticResponse: ({ input }) => ({
createPost: {
__typename: 'Post',
id: 'temp-' + Date.now(),
slug: input.title.toLowerCase().replace(/\s+/g, '-'),
title: input.title,
status: 'DRAFT',
author: { __typename: 'User', id: 'current', name: 'You' },
},
}),
update(cache, { data: { createPost: newPost } }) {
cache.modify({
fields: {
posts(existingRefs = []) {
const ref = cache.writeFragment({
data: newPost,
fragment: gql`fragment NewPost on Post { id slug title status author { id name } }`,
});
return [ref, ...existingRefs];
},
},
});
},
});
// --- useSubscription: receive real-time published posts ---
useSubscription(POST_PUBLISHED, {
onData: ({ client, data }) => {
const published = data.data?.postPublished;
if (published) {
client.cache.modify({
fields: {
posts(existing = []) {
const ref = client.cache.writeFragment({
data: published,
fragment: gql`fragment PubPost on Post { id slug title author { name } }`,
});
return [ref, ...existing];
},
},
});
}
},
});
if (error) return <div>Error: {error.message}</div>;
if (loading && !data) return <div>Loading posts...</div>;
const { posts = [] } = data ?? {};
return (
<div>
<button
disabled={creating}
onClick={() => createPost({ variables: { input: { title: 'New Post', body: '...' } } })}
>
{creating ? 'Creating...' : 'New Post'}
</button>
{posts.map((post: any) => (
<article key={post.slug}>
<h2>{post.title}</h2>
<small>by {post.author.name}</small>
</article>
))}
<button onClick={() => fetchMore({ variables: { offset: posts.length } })}>
Load more
</button>
</div>
);
}Authentication and Authorization
Authentication in GraphQL is handled at the HTTP layer — typically via Authorization header with a JWT or session cookie — before any resolvers run. Apollo Server's context function runs on every request, decodes the token, and attaches the user to context, making it available to all resolvers.
Authorization (who can do what) is more nuanced in GraphQL because a single endpoint handles all operations. Common patterns include resolver-level guards (check context.user before resolving), schema directives (@auth, @hasRole), and middleware layers. The graphql-shield library provides a permission layer using rule composition.
JWT Context Function + graphql-shield Permission Rules
// auth/jwt.ts
import jwt from 'jsonwebtoken';
export async function verifyJWT(token: string): Promise<User | null> {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string; role: string };
return await db.users.findById(payload.userId);
} catch {
return null;
}
}
// Context function (runs on every request before any resolver)
export async function createContext({ req }: { req: Request }) {
const auth = req.headers.get('authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null;
const user = token ? await verifyJWT(token) : null;
return { user, db, loaders: createLoaders(db), pubsub, redis };
}
// auth/permissions.ts -- graphql-shield
import { shield, rule, and, or } from 'graphql-shield';
const isAuthenticated = rule({ cache: 'contextual' })(
async (_, __, ctx: Context) => ctx.user !== null || 'Must be logged in'
);
const isAdmin = rule({ cache: 'contextual' })(
async (_, __, ctx: Context) => ctx.user?.role === 'ADMIN' || 'Admin only'
);
const isPostOwner = rule({ cache: 'strict' })(
async (_, { id }, ctx: Context) => {
const post = await ctx.db.posts.findById(id);
return post?.authorId === ctx.user?.id || 'Not your post';
}
);
export const permissions = shield(
{
Query: { me: isAuthenticated, users: isAdmin },
Mutation: {
createPost: isAuthenticated,
updatePost: and(isAuthenticated, or(isPostOwner, isAdmin)),
deletePost: and(isAuthenticated, or(isPostOwner, isAdmin)),
},
},
{ fallbackError: 'Not authorized', allowExternalErrors: true }
);
// Schema directive alternative (@auth in SDL)
// directive @auth on FIELD_DEFINITION
// type Mutation {
// createPost(input: CreatePostInput!): Post! @auth
// }
//
// Transformer using @graphql-tools/schema:
// mapSchema(schema, {
// [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
// if (!getDirective(schema, fieldConfig, 'auth')?.[0]) return fieldConfig;
// const { resolve = defaultFieldResolver } = fieldConfig;
// return { ...fieldConfig, resolve(src, args, ctx, info) {
// if (!ctx.user) throw new AuthenticationError();
// return resolve(src, args, ctx, info);
// }};
// },
// });Pagination: Cursor-Based & Relay Spec
GraphQL supports two main pagination approaches: offset-based (skip/take or page/limit) and cursor-based. Offset pagination is simple but breaks under concurrent inserts or deletes. Cursor-based pagination uses an opaque cursor (typically a base64-encoded ID or timestamp) to point to a specific position in the dataset, making it stable under data mutations.
The Relay Connection Specification is the industry-standard cursor pagination contract. It defines a Connection type with edges (array of edge objects, each containing a node and cursor) and pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor). Most GraphQL clients including Apollo Client natively understand the Relay spec.
Relay Cursor Pagination — Server & Client
// Server: postsConnection resolver
async function postsConnection(
_: unknown,
{ filter, first = 10, after }: { filter?: any; first?: number; after?: string },
{ db }: Context
) {
// Decode the opaque cursor (base64 of the row ID)
const afterId = after
? Buffer.from(after, 'base64').toString('utf-8')
: null;
// Fetch one extra row to determine if there is a next page
const rows = await db.posts.findMany({
where: { ...filter, ...(afterId ? { id: { gt: afterId } } : {}) },
orderBy: { createdAt: 'desc' },
take: first + 1,
});
const hasNextPage = rows.length > first;
const nodes = rows.slice(0, first);
const edges = nodes.map((node: any) => ({
node,
cursor: Buffer.from(String(node.id)).toString('base64'),
}));
return {
edges,
totalCount: await db.posts.count({ where: filter }),
pageInfo: {
hasNextPage,
hasPreviousPage: !!afterId,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
}
// Client: useQuery with fetchMore (infinite scroll)
const POSTS_CONNECTION = gql`
query PostsConnection($filter: PostFilter, $first: Int!, $after: String) {
postsConnection(filter: $filter, first: $first, after: $after) {
edges { cursor node { id slug title author { name } } }
pageInfo { hasNextPage endCursor }
totalCount
}
}
`;
function PostsPaginated() {
const { data, fetchMore } = useQuery(POSTS_CONNECTION, {
variables: { first: 10 },
});
const conn = data?.postsConnection;
return (
<div>
{conn?.edges.map(({ node }: any) => (
<article key={node.id}><h3>{node.title}</h3></article>
))}
{conn?.pageInfo.hasNextPage && (
<button
onClick={() =>
fetchMore({
variables: { after: conn.pageInfo.endCursor },
updateQuery(prev, { fetchMoreResult: next }) {
if (!next) return prev;
return {
postsConnection: {
...next.postsConnection,
edges: [
...prev.postsConnection.edges,
...next.postsConnection.edges,
],
},
};
},
})
}
>
Load more ({conn.totalCount - conn.edges.length} remaining)
</button>
)}
</div>
);
}Error Handling in GraphQL
GraphQL has a unique error model: every response returns HTTP 200, even when errors occur. Errors appear in a top-level errors array alongside any partial data. This differs fundamentally from REST where HTTP status codes communicate error categories. The errors array contains objects with message, locations, path (which field failed), and extensions (custom error codes).
Best practice is to always return HTTP 200 for GraphQL errors and use the errors array, but return non-200 status only for transport-level failures (malformed JSON, missing query). In Apollo Server, throwing ApolloError (or its subclasses: AuthenticationError, ForbiddenError, UserInputError, NotFoundError) automatically populates the extensions.code field for clients to handle programmatically.
Custom Error Classes + Response Shape + Client Handling
// errors.ts -- Custom error types with error codes
import { GraphQLError } from 'graphql';
export class AuthenticationError extends GraphQLError {
constructor(msg = 'Authentication required') {
super(msg, { extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } } });
}
}
export class ForbiddenError extends GraphQLError {
constructor(msg = 'Forbidden') {
super(msg, { extensions: { code: 'FORBIDDEN', http: { status: 403 } } });
}
}
export class NotFoundError extends GraphQLError {
constructor(resource: string) {
super(resource + ' not found', { extensions: { code: 'NOT_FOUND' } });
}
}
export class ValidationError extends GraphQLError {
constructor(msg: string, fields?: Record<string, string>) {
super(msg, { extensions: { code: 'VALIDATION_ERROR', fields } });
}
}
// GraphQL response with errors (HTTP 200 always):
// {
// "data": { "createPost": null },
// "errors": [{
// "message": "Authentication required",
// "locations": [{ "line": 2, "column": 3 }],
// "path": ["createPost"],
// "extensions": { "code": "UNAUTHENTICATED" }
// }]
// }
// Partial data with errors (errorPolicy: "all"):
// {
// "data": { "me": { "name": "Alice" }, "adminStats": null },
// "errors": [{ "path": ["adminStats"], "extensions": { "code": "FORBIDDEN" } }]
// }
// Client: programmatic error handling by code
function useTypedMutation(mutation: any, options?: any) {
const [mutate, result] = useMutation(mutation, {
errorPolicy: 'all',
...options,
});
function getErrorByCode(code: string) {
return result.error?.graphQLErrors.find(
(e) => e.extensions?.code === code
);
}
return [
mutate,
{
...result,
isUnauthenticated: !!getErrorByCode('UNAUTHENTICATED'),
isForbidden: !!getErrorByCode('FORBIDDEN'),
isNotFound: !!getErrorByCode('NOT_FOUND'),
validationFields: getErrorByCode('VALIDATION_ERROR')?.extensions?.fields,
isNetworkError: !!result.error?.networkError,
},
] as const;
}Performance: DataLoader, Caching & Persisted Queries
The N+1 problem is the most common GraphQL performance pitfall. When resolving a list of posts and each post resolver fetches its author separately, you get N+1 database queries (1 for the list + N for authors). DataLoader solves this by batching all individual load() calls within a single event loop tick into a single batch request, and memoizing results within the same request.
Response caching can be implemented at multiple levels: Apollo Server's built-in @cacheControl directives set HTTP cache headers, Redis caching at the resolver level caches expensive computations, and Apollo Client's InMemoryCache handles client-side caching. Persisted queries replace full query strings with a hash, reducing request size and enabling GET-based CDN caching for queries.
DataLoader Batching + Redis Resolver Cache + APQ
// DataLoader: the N+1 solution
// Without DataLoader:
// Resolving 20 posts -> 20 x "SELECT * FROM users WHERE id = $1" = 21 queries
// With DataLoader:
// Resolving 20 posts -> 1 x "SELECT * FROM users WHERE id IN (...)" = 2 queries
import DataLoader from 'dataloader';
export function createUserLoader(db: any) {
return new DataLoader<string, any>(
async (ids) => {
const rows = await db.query(
'SELECT * FROM users WHERE id = ANY($1::uuid[])',
[[...ids]]
);
const map = new Map(rows.map((r: any) => [r.id, r]));
return ids.map(id => map.get(id) ?? new Error('User ' + id + ' not found'));
},
{ maxBatchSize: 500, cache: true }
);
}
// Redis caching at resolver level
import type { Redis } from 'ioredis';
async function cachedResolver<T>(
redis: Redis,
key: string,
ttlSeconds: number,
fetchFn: () => Promise<T>
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached) as T;
const result = await fetchFn();
await redis.setex(key, ttlSeconds, JSON.stringify(result));
return result;
}
// Usage in resolver:
Query: {
posts: async (_, args, { db, redis }) =>
cachedResolver(
redis,
'posts:' + JSON.stringify(args),
60, // 60 second TTL
() => db.posts.findMany(args)
),
},
// Automatic Persisted Queries (APQ)
// -- cuts request body from ~500B to ~50B
// -- enables GET requests for CDN caching
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const apqLink = createPersistedQueryLink({
sha256,
useGETForHashedQueries: true, // Enables CDN GET caching
});
// Client flow:
// 1. Send: POST /graphql { extensions: { persistedQuery: { sha256Hash: "abc" } } }
// 2. Server miss: returns PersistedQueryNotFound
// 3. Client retries: POST /graphql { query: "...", extensions: { persistedQuery: { sha256Hash: "abc" } } }
// 4. Server stores hash, executes, responds
// 5. Next time: GET /graphql?extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%22abc%22%7D%7D
// 6. CDN can cache this GET response!
// @cacheControl directive (HTTP cache headers)
// type Query {
// publicPosts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC)
// me: User @cacheControl(maxAge: 0, scope: PRIVATE)
// }GraphQL vs REST vs gRPC vs tRPC
Choosing the right API paradigm depends on your use case, team expertise, and infrastructure. Here is a comprehensive comparison of the four major API approaches in 2026:
| Feature | GraphQL | REST | gRPC | tRPC |
|---|---|---|---|---|
| Transport | HTTP + WebSocket | HTTP/1.1 or HTTP/2 | HTTP/2 (binary) | HTTP (JSON) |
| Endpoint | Single /graphql | One per resource | Service methods | Type-safe procedures |
| Contract | SDL (.graphql file) | OpenAPI (optional) | Protocol Buffers | TypeScript types |
| Type Safety | codegen required | OpenAPI + codegen | Built-in (proto gen) | Built-in (inferred) |
| Over-fetching | Solved by design | Common problem | Solved (streaming) | Controlled per procedure |
| HTTP Caching | Hard (needs APQ) | Simple GET caching | N/A | Needs setup |
| Real-time | Subscriptions (WS) | SSE or WebSocket | Bidirectional streaming | Subscriptions |
| File Upload | Multipart spec | Native multipart | Not standard | Separate REST endpoint |
| Browser | Full support | Full support | grpc-web proxy only | Full support |
| Learning Curve | Medium-High | Low | High | Low (TypeScript devs) |
| N+1 Problem | DataLoader required | Managed per endpoint | Streaming mitigates | Co-location avoids it |
| Versioning | Schema evolution | URL /v1 /v2 | Proto backward compat | TypeScript refactor |
| Ecosystem | Very large (Apollo) | Largest (universal) | Large (Google) | Growing (Vercel) |
| Best For | Multi-client flexible APIs | Public/simple APIs | Internal microservices | Full-stack TS monorepo |
Decision Guide: When to Use Each Approach
- Multiple client types (web/mobile/IoT)
- Complex nested data requirements
- Rapid frontend iteration
- Real-time subscriptions needed
- Developer-facing flexible API
- Simple CRUD, URLs map to resources
- Public API (universally understood)
- Heavy HTTP GET caching
- File upload/download workflows
- Webhook / 3rd-party integrations
- High-performance internal services
- Polyglot microservices (multi-language)
- Bidirectional streaming required
- Low-latency critical path
- Contract-first with .proto files
- Full-stack TypeScript monorepo
- Next.js with shared type definitions
- Small team, no separate API layer
- Zero codegen, end-to-end types
- Avoid REST/GraphQL boilerplate
Frequently Asked Questions
Does GraphQL solve the over-fetching and under-fetching problem?
Yes, this is GraphQL's primary design goal. Clients specify exactly which fields they need in their query, so the server returns precisely that data — no more, no less. This eliminates over-fetching (REST endpoints returning unused fields) and under-fetching (needing multiple REST round trips to assemble a complete page's data). For a complex dashboard page that needs user info, recent orders, and notifications, GraphQL does it in one request; REST would require three.
How do GraphQL subscriptions work and when should I use them?
Subscriptions are a GraphQL operation type (alongside Query and Mutation) that enables real-time data push from server to client via WebSocket connections. The server publishes events using a PubSub mechanism, and subscribed clients receive updates automatically. Use subscriptions for chat messages, live notifications, collaborative editing, stock price feeds, and any feature where clients need to react to server-side state changes in real time. For less frequent updates, polling with useQuery's pollInterval option is simpler to implement and scale.
What is the difference between code-first and schema-first GraphQL?
Schema-first (SDL-first) means you write the .graphql schema file manually, then write resolvers to match it. Code-first means you define your types in code (TypeScript decorators with TypeGraphQL, or objects with Pothos), and the SDL is generated automatically. Schema-first gives you the schema as a contract visible to all teams upfront; code-first avoids schema/resolver drift since types are co-located. Most large teams prefer schema-first for the explicit contract, while code-first is popular in TypeScript projects for end-to-end type safety without codegen.
What is GraphQL Federation and when should I use it?
GraphQL Federation (Apollo Federation) is a specification for composing multiple GraphQL subgraph services into a single unified supergraph API. Each team owns their subgraph schema (e.g., Users, Orders, Products), and a Router/Gateway stitches them together transparently. Use Federation when you have multiple backend services that each own part of the data graph and need to expose a unified API to clients — the microservices pattern for GraphQL. For monoliths or small teams, a single Apollo Server instance is simpler.
How does GraphQL handle file uploads?
GraphQL's default transport (JSON over HTTP) does not natively support binary file uploads. The community-standard solution is the GraphQL Multipart Request Specification (graphql-upload package for Node.js), which extends the GraphQL HTTP protocol to accept multipart/form-data with file streams mapped to query variables. Alternatively, many teams use a separate REST endpoint for file uploads and reference the returned URL in GraphQL mutations, keeping GraphQL for structured data only.
Is GraphQL harder to cache than REST?
Yes, HTTP-level caching is harder with GraphQL because all requests go to a single POST /graphql endpoint, so HTTP caches cannot differentiate responses by URL. Workarounds include: persisted queries (assign a hash ID to queries, use GET requests for cacheable queries — Apollo Client and Apollo Server both support this), Apollo Client's normalized InMemoryCache for client-side caching, CDN caching with Automatic Persisted Queries (APQ), and server-side response caching with Redis keyed on query + variables hash.
What tools exist for GraphQL development and testing?
Key tools: GraphiQL and Apollo Sandbox (in-browser IDE with schema explorer and query builder), Apollo Studio (cloud-based schema registry, operation metrics, alerts), GraphQL Inspector (schema diffing, breaking change detection), graphql-codegen (generate TypeScript types and React hooks from schema + operations), Rover CLI (Apollo Federation schema management), Postman and Insomnia (support GraphQL operations), and Apollo DevTools browser extension (inspect cache, active queries, mutations). For testing: Jest with @apollo/client MockedProvider for React components, and graphql-faker for generating mock data from schema.
When should I NOT use GraphQL?
Avoid GraphQL for: simple CRUD APIs where REST endpoints map naturally to resources; public APIs where consumers expect REST (most third-party developers know REST); file upload/download heavy workflows; services that primarily use HTTP caching (REST GET caching is much simpler); very small teams where GraphQL's setup overhead is not justified by benefits; and APIs consumed primarily by server-to-server calls where the flexibility of client-defined queries adds no value. tRPC is an excellent alternative for TypeScript monorepos where frontend and backend share the same codebase.