GraphQL Best Practices for Production
GraphQL offers flexibility but introduces unique challenges. This guide covers production-ready patterns.
Schema Design Principles
# Good: Use connections for lists (enables pagination)
type Query {
users(first: Int, after: String, filter: UserFilter): UserConnection!
user(id: ID!): User
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Good: Use input types for mutations
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
input CreateUserInput {
name: String!
email: String!
role: UserRole = USER
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code: UserErrorCode!
}
Solving N+1 with DataLoader
import DataLoader from 'dataloader';
// Without DataLoader - N+1 problem
// Querying 100 posts = 100 separate user queries!
// With DataLoader - batched queries
const userLoader = new DataLoader<string, User>(async (userIds) => {
// Single query for all IDs
const users = await db.users.findMany({
where: { id: { in: userIds as string[] } },
});
// Return in same order as input IDs
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
});
// In resolver
const resolvers = {
Post: {
author: (post, _, { loaders }) => {
return loaders.user.load(post.authorId);
},
tags: (post, _, { loaders }) => {
return loaders.tagsByPost.load(post.id);
},
},
};
// Context setup - new DataLoader per request!
function createContext({ req }) {
return {
db,
loaders: {
user: new DataLoader(batchUsers),
tagsByPost: new DataLoader(batchTagsByPost),
},
};
}
Cursor-Based Pagination
async function resolveUsersConnection(
parent: unknown,
args: { first?: number; after?: string; filter?: UserFilter },
ctx: Context,
) {
const limit = Math.min(args.first ?? 20, 100);
const cursor = args.after ? decodeCursor(args.after) : null;
const users = await ctx.db.users.findMany({
take: limit + 1, // Fetch one extra to check hasNextPage
where: {
...(cursor && { id: { gt: cursor } }),
...buildFilterWhere(args.filter),
},
orderBy: { id: 'asc' },
});
const hasNextPage = users.length > limit;
const edges = users.slice(0, limit).map(user => ({
node: user,
cursor: encodeCursor(user.id),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!cursor,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: () => ctx.db.users.count({ where: buildFilterWhere(args.filter) }),
};
}
function encodeCursor(id: string): string {
return Buffer.from(`cursor:${id}`).toString('base64');
}
function decodeCursor(cursor: string): string {
const decoded = Buffer.from(cursor, 'base64').toString();
return decoded.replace('cursor:', '');
}
Authentication and Authorization
// Schema directives for authorization
const typeDefs = gql`
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { USER ADMIN }
type Query {
me: User @auth
adminStats: Stats @auth(requires: ADMIN)
}
`;
// Field-level authorization with shield
import { shield, rule, and, not } from 'graphql-shield';
const isAuthenticated = rule({ cache: 'contextual' })(
async (parent, args, ctx) => {
return !!ctx.user;
}
);
const isAdmin = rule({ cache: 'contextual' })(
async (parent, args, ctx) => {
return ctx.user?.role === 'ADMIN';
}
);
const isOwner = rule({ cache: 'strict' })(
async (parent, args, ctx) => {
const post = await ctx.loaders.post.load(args.id);
return post.authorId === ctx.user?.id;
}
);
export const permissions = shield({
Query: {
me: isAuthenticated,
adminStats: isAdmin,
},
Mutation: {
createPost: isAuthenticated,
updatePost: and(isAuthenticated, isOwner),
deletePost: and(isAuthenticated, or(isOwner, isAdmin)),
},
});
Query Complexity and Depth Limiting
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(10), // Max query depth
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
introspectionListFactor: 2,
}),
],
});
// Custom complexity calculator
function calculateComplexity(args: Record<string, any>): number {
const limit = args.first || args.limit || 10;
return limit * 2; // Each item costs 2
}
Persisted Queries (Security + Performance)
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
// Client: automatically hash and send query IDs
const link = createPersistedQueryLink({ sha256 });
const client = new ApolloClient({ link, cache: new InMemoryCache() });
// Server: verify and lookup queries
const server = new ApolloServer({
cache: new InMemoryLRUCache(),
plugins: [
ApolloServerPluginCacheControl({ defaultMaxAge: 5 }),
{
async requestDidStart({ request }) {
if (request.extensions?.persistedQuery) {
const { sha256Hash } = request.extensions.persistedQuery;
// Verify against allowlist in production
}
},
},
],
});
Summary
GraphQL production checklist:
- Use connections for all list fields (enables pagination)
- Use input types for all mutations
- DataLoader for batching (solve N+1)
- Cursor-based pagination (not offset)
- Query depth and complexity limits
- Persisted queries to prevent malicious queries
- Field-level authorization with graphql-shield