Apollo Server is the most widely deployed GraphQL server implementation for Node.js, and most of the pain reported about GraphQL in production — N+1 queries, runaway query cost, unclear errors — traces back to a handful of patterns Apollo makes easy to get wrong and just as easy to get right once you know where to look.
Resolvers and the N+1 problem
A naive resolver for a nested field — say, fetching the author of each post in a list of posts — issues one database query per post if written straight-forwardly, turning a single request for 50 posts into 51 queries. This is the single most common GraphQL performance bug, and the standard fix is DataLoader: a per-request batching and caching layer that collects all the individual "get author by ID" calls made during one tick of the event loop and turns them into a single batched query.
const authorLoader = new DataLoader(async (ids) => {
const authors = await db.author.findMany({
where: { id: { in: ids } }
})
const byId = new Map(authors.map(a => [a.id, a]))
return ids.map(id => byId.get(id))
})
const resolvers = {
Post: {
author: (post, _args, { loaders }) =>
loaders.authorLoader.load(post.authorId),
},
}
DataLoader caches within its own lifetime. A DataLoader shared across requests will leak stale data between users and, in the worst case, leak one user's data into another user's response. Instantiate it fresh in your context function on every request.
Schema design: nullable by default, non-null deliberately
GraphQL's type system tempts teams into marking every field non-null because it looks stricter and safer. In practice this is backwards: if any single field in a non-null chain fails to resolve, GraphQL nulls out the entire parent object up to the nearest nullable ancestor — a failure in one minor field can wipe out an entire response tree. The safer default is nullable fields everywhere except identifiers, with non-null reserved for fields you're certain can never fail to resolve. This makes partial failures degrade gracefully instead of cascading.
Query complexity and depth limiting
Because GraphQL lets clients construct arbitrarily nested queries, an unbounded schema is a denial-of-service vector — a deeply nested query against circular relationships (posts → author → posts → author...) can generate exponential backend work from a small request payload. Apollo Server supports plugins for query depth limiting and cost analysis (assigning a computed "cost" to each field and rejecting queries above a threshold) — this is not optional hardening for a production public-facing schema, it's baseline.
For client apps you control, persisted queries — where the client sends a hash instead of the full query text, and the server only executes pre-registered queries — close off arbitrary query construction entirely while also shrinking request payloads.
Error handling that clients can actually act on
GraphQL's default error format (a flat errors array with a message and path) is too generic for clients to branch logic on reliably. Apollo Server supports typed errors via extensions — attaching a machine-readable code (e.g., UNAUTHENTICATED, VALIDATION_FAILED) alongside the human-readable message — so clients can distinguish "retry this" from "show this error to the user" from "the user needs to log in again" without string-matching on error text.
Caching in a single-endpoint world
Because every GraphQL request hits the same URL via POST, the HTTP caching that works for free with REST doesn't apply. Apollo Server's response cache plugin can cache full query responses server-side keyed by the query and variables, and field-level caching hints let you mark specific fields as cacheable for a TTL — but both require deliberate setup, unlike REST where a CDN can cache a GET request without any application-level configuration at all.
| Pattern | Problem it solves |
|---|---|
| DataLoader batching | N+1 queries in nested resolvers |
| Nullable-by-default schema | Prevents cascading nulls from one failed field |
| Query depth/cost limiting | Denial-of-service via deeply nested queries |
| Typed error extensions | Clients can branch on error code, not message text |
Wrapping up
Most Apollo Server problems in production trace back to one of two omissions: no DataLoader (so N+1 queries), or no depth/cost limiting (so an unbounded query surface). Get those two in place from the start, keep the schema nullable by default, and the rest of what makes GraphQL painful in production mostly doesn't happen.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.