GraphQL Best Practices for Production
My GraphQL API brought down my database on launch day.
One query. One user. 10,000 database queries in 3 seconds.
I'd heard about the N+1 problem but thought "that won't happen to me." It did. Here's what I learned.
The N+1 Problem (It Will Bite You)
Here's what happened. I had this query:
query { users { id name posts { title } } }
Looks innocent. Here's what my resolver did:
const resolvers = { Query: { users: () => db.users.findMany() // 1 query }, User: { posts: (user) => db.posts.findMany({ userId: user.id }) // N queries! } }
For 100 users, that's 1 query for users + 100 queries for posts = 101 database queries.
My database couldn't handle it.
Fix: DataLoader
DataLoader batches and caches database queries:
const DataLoader = require('dataloader') const postLoader = new DataLoader(async (userIds) => { // One query for all users const posts = await db.posts.findMany({ where: { userId: { in: userIds } } }) // Group by userId return userIds.map(id => posts.filter(post => post.userId === id) ) }) const resolvers = { User: { posts: (user) => postLoader.load(user.id) } }
Now 100 users = 2 queries total. Database is happy.
Schema Design: Think Like a Client
I made my schema match my database structure. Bad idea.
# Bad: Database-focused type User { id: ID! first_name: String! last_name: String! email_address: String! }
Clients don't care about your database columns. They care about what they need:
# Good: Client-focused type User { id: ID! fullName: String! email: String! avatar: String }
Way better. Clients get what they need without knowing your database structure.
Query Complexity: Prevent Expensive Queries
Someone tried this query on my API:
query { users { posts { comments { author { posts { comments { # ... you get the idea } } } } } } }
This would've queried my entire database. I stopped it with query complexity limits:
const { createComplexityLimitRule } = require('graphql-validation-complexity') const server = new ApolloServer({ schema, validationRules: [ createComplexityLimitRule(1000, { onCost: (cost) => { console.log('Query cost:', cost) } }) ] })
Now expensive queries get rejected before they hit the database.
Authentication: Check It Everywhere
I had authentication on my GraphQL endpoint but forgot to check it in resolvers.
// Bad: No auth check const resolvers = { Query: { sensitiveData: () => getSensitiveData() } }
Anyone could query sensitive data. Oops.
Fixed it:
// Good: Check auth in resolver const resolvers = { Query: { sensitiveData: (parent, args, context) => { if (!context.user) { throw new Error('Not authenticated') } if (!context.user.hasPermission('read:sensitive')) { throw new Error('Not authorized') } return getSensitiveData() } } }
Now every resolver checks permissions.
Rate Limiting: Protect Your API
I got hit with 1000 requests in 10 seconds from one IP. My server crashed.
Added simple rate limiting:
const rateLimit = require('express-rate-limit') const limiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100, // 100 requests per minute message: 'Too many requests' }) app.use('/graphql', limiter)
Problem solved. Costs nothing, saves your server.
Caching: The Easy Win
I was hitting my database for the same data over and over. Added simple caching:
const cache = new Map() const resolvers = { Query: { user: async (parent, { id }) => { // Check cache first const cached = cache.get(`user:${id}`) if (cached) return cached // Query database const user = await db.users.findUnique({ where: { id } }) // Cache for 5 minutes cache.set(`user:${id}`, user) setTimeout(() => cache.delete(`user:${id}`), 5 * 60 * 1000) return user } } }
Database load dropped by 60%. For 10 lines of code.
What I Didn't Need
Subscriptions: Thought I needed real-time updates. I didn't. Polling every 30 seconds works fine for my use case.
Federation: Considered splitting my GraphQL API into multiple services. Way too complex for a solo project.
Persisted queries: Looked cool but didn't actually help my performance.
The Results
Before:
- Database crashed under load
- No rate limiting
- No caching
- N+1 queries everywhere
After:
- Handles 10x more traffic
- Rate limited and secure
- 60% less database load
- DataLoader prevents N+1
Cost: $0. Just better code.
Should You Use GraphQL?
Depends.
If you're building a REST API and it's working fine, don't switch. GraphQL isn't automatically better.
If you're building a new API and clients need flexible data fetching, GraphQL is great. Just watch out for N+1 queries and implement DataLoader from day one.
And for the love of all that is holy, add query complexity limits before you go to production.
Building GraphQL APIs? I'd love to hear what tripped you up. Hit me up on LinkedIn.