Skip to content

Best Practices

GraphQL gives clients a lot of power — but with great power comes great responsibility. This page covers pagination, security, performance, and common mistakes.

Analogy: GraphQL is like an all-you-can-eat buffet. Best practices are the restaurant rules: use a plate (pagination), don’t take more than you can eat (query limits), and keep the serving area clean (security).


Always paginate list fields. Never return unbounded arrays.

# ❌ Bad — no limit
type Query {
users: [User!]!
}
# ✅ Good — cursor-based pagination (Relay spec)
type Query {
users(first: Int!, after: String): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# ✅ Simpler alternative — offset pagination
type Query {
users(page: Int, limit: Int): [User!]!
}

Prevent clients from writing deeply nested queries that crash your server:

import { depthLimit } from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // Max 5 levels deep
});
// ❌ Blocked: depth of 6+
query {
user { posts { author { posts { author { posts { title } } } } } }
}
// ✅ Allowed: depth of 4
query {
user { name posts { title author { name } } }
}

Some queries are more expensive than others:

import { simpleEstimator, createComplexityRule } from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityRule({
estimators: [simpleEstimator({ defaultComplexity: 1 })],
maximumComplexity: 100,
}),
],
});
// ✅ Allowed: complexity = 6
query { users { name email } }
// ❌ Blocked: complexity = 105
query {
users { posts { comments { author { posts { comments { text } } } } } }
posts { author { posts { comments { author { name } } } } }
}

4. Security — Authentication & Authorization

Section titled “4. Security — Authentication & Authorization”
// Context-based auth
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
user: getUserFromToken(req.headers.authorization),
}),
});
// Resolver-level authorization
const resolvers = {
Mutation: {
deletePost: async (_, { id }, { db, user }) => {
if (!user) throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
const post = await db.post.findUnique({ where: { id } });
if (post.authorId !== user.id) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return await db.post.delete({ where: { id } });
},
},
};

flowchart TB
Request[Incoming GraphQL Request] --> Auth[Authentication<br/>Who is this?]
Auth -->|Valid Token| Authz[Authorization<br/>Can they do this?]
Auth -->|Invalid Token| Reject[Reject ❌]
Authz -->|Authorized| Depth[Depth Check<br/>Not too deep?]
Authz -->|Forbidden| Reject2[Reject ❌]
Depth -->|Pass| Complexity[Complexity Check<br/>Not too expensive?]
Depth -->|Too Deep| Reject3[Reject ❌]
Complexity -->|Pass| Rate[Rate Limit<br/>Not too many?]
Complexity -->|Too Expensive| Reject4[Reject ❌]
Rate -->|Under Limit| Execute[Execute Resolvers ✅]
Rate -->|Over Limit| Reject5[Reject ❌]
style Request fill:#3b82f6,color:#fff
style Execute fill:#059669,color:#fff
style Reject fill:#ef4444,color:#fff
style Reject2 fill:#ef4444,color:#fff
style Reject3 fill:#ef4444,color:#fff
style Reject4 fill:#ef4444,color:#fff
style Reject5 fill:#ef4444,color:#fff

Always use DataLoader for related data (covered in detail on the Connecting Data page):

// 📦 Always batch requests for nested resolvers
const resolvers = {
User: {
posts: (parent, _, { postLoader }) => postLoader.load(parent.id),
},
};
# ✅ Good naming
type Query {
getUser(id: ID!): User # Verb + noun
listUsers(filter: UserFilter): [User!]! # List prefix for arrays
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
# ❌ Avoid
type Query {
user: User # Unclear what it returns
usersList: [User] # Inconsistent naming
}

MistakeWhy It’s WrongFix
No paginationClient can query millions of recordsAlways paginate list fields
No depth limitMalicious nested queries crash serverUse graphql-depth-limit
N+1 queriesPerformance disaster for nested dataUse DataLoader
Returning raw DB errorsLeaks internal infoCatch and sanitize errors
No input validationBad data pollutes your databaseValidate in resolvers
Global DataLoaderCached data leaks across requestsOne DataLoader per request
Over-fetching authQuerying DB for every resolverPut user in context
Ignoring __typenameCache issues with Apollo ClientAlways include typename

# ✅ Design for the client's needs
type Query {
# Specific queries for common use cases
currentUser: User
recentPosts(limit: Int = 10): [Post!]!
# Generic queries for flexibility
user(id: ID!): User
users(filter: UserFilter, pagination: Pagination): UserConnection!
}
# ✅ Use interfaces for shared fields
interface Node {
id: ID!
createdAt: String!
updatedAt: String!
}
type User implements Node {
id: ID!
createdAt: String!
updatedAt: String!
name: String!
email: String!
}
type Post implements Node {
id: ID!
createdAt: String!
updatedAt: String!
title: String!
content: String!
}

  • Pagination every list — never return unbounded arrays
  • Limit query depth (max 5-7 levels) and complexity to prevent abuse
  • Always authenticate and authorize — never trust the client
  • Use DataLoader to solve N+1 issues; one DataLoader per request
  • Design schema for the client and sanitize errors to hide internals