GraphQL Fundamentals and the N+1 Problem
Concept
REST's resource-per-endpoint shape forces a choice at design time between two failure modes on any nested-data screen: over-fetching (the endpoint returns the whole resource because different clients need different subsets of its fields) or under-fetching (the client needs data from several resources and has to issue several round trips, or the backend grows a bespoke aggregating endpoint per screen). GraphQL, developed at Facebook and released publicly in 2015, replaces per-resource endpoints with a single endpoint and a query language: the client sends a query describing the exact shape of data it wants — specific fields, arbitrarily nested — and the server returns exactly that shape, no more and no less, in one round trip.
The server side is built from a schema (a typed contract describing every type and field the API exposes) and resolvers — one function per field, responsible for producing that field's value. A query executes as a tree: the top-level resolver runs first, then each of its child fields' resolvers run (in parallel, since GraphQL makes no ordering guarantee between sibling fields), recursively, until every requested field has been resolved. This per-field resolver model is what makes GraphQL's flexibility possible — any client can ask for any combination of fields without the server needing a bespoke endpoint for that combination — but it comes at a cost described below.
That cost is the N+1 problem. Resolvers are independent by design — a field's resolver has no visibility into its sibling resolvers running alongside it. Querying a list of 50 posts, each with an author field, naively executes one query for the posts and then a separate query for each post's author — 51 queries where one join or one batched WHERE id IN (...) would have done. The bug is structural, not a mistake in any single resolver: each resolver is correct in isolation, and the inefficiency only appears in aggregate across a query's full execution tree.
DataLoader (originated at Facebook alongside GraphQL itself, now the de facto standard pattern) fixes this without giving up the per-field resolver model. It batches .load(key) calls made within the same tick of the event loop into a single batchLoadFn(keys) call, and caches results for the lifetime of one request — so the 50 sibling author resolvers each call authorLoader.load(id), and DataLoader collapses those 50 calls into one query fetching all needed author IDs at once (deduplicating repeated IDs along the way).
Tradeoffs
| Approach | Round trips for nested data | Fetch precision | Cost |
|---|---|---|---|
| REST, one endpoint per resource | Many (one per resource, unless a bespoke aggregate endpoint is built) | Over-fetches (whole resource) or under-fetches (needs multiple calls) | Simple per-endpoint caching (HTTP caching works out of the box) |
| REST, bespoke aggregate/BFF endpoint | One, but only for that exact screen | Precise for the one screen it was built for | Endpoint sprawl — a new endpoint per screen shape, maintenance cost grows with UI surface |
| GraphQL, naive resolvers | One network round trip, but N+1 database/service calls hidden behind it | Exactly what the client asked for | The N+1 problem — silent until load-tested or run against a large enough list |
| GraphQL + DataLoader | One network round trip, batched backend calls | Exactly what the client asked for | Added complexity: every resolver that fetches by ID must be rewritten to go through a loader, and loaders must be re-instantiated per request (never shared across requests, or one user's cached data leaks into another's) |
The real tradeoff isn't "GraphQL vs REST" in the abstract — it's that GraphQL moves the over/under-fetching problem from the network (REST's failure mode) to the backend (GraphQL's N+1 failure mode), and DataLoader is the standard tool for paying that cost back down without sacrificing the flexibility that motivated GraphQL in the first place.
When to use / when not to
- Use where clients have genuinely varied data needs over deeply nested, related data — a storefront page combining a service, its provider, and reviews in one view is the canonical case: REST either over-fetches per resource or needs a bespoke aggregate endpoint per page variant.
- Use at the edge of a system with many client types (web, iOS, Android) that each want different subsets of the same underlying data — one schema serves all of them without per-client endpoints.
- Don't reach for it for a simple, shallow CRUD API with one client and no nested-fetch pain — REST's simplicity, cacheability, and debuggability (curl a URL, read the response) aren't worth trading away for flexibility nothing is asking for yet.
- Don't ship GraphQL resolvers that fetch by ID without a DataLoader (or equivalent batching) in front of them — this is the single most common way a GraphQL API works fine in development (small test data) and falls over at real list sizes in production.
Common pitfall
Treating GraphQL's one-network-round-trip property as proof the query is efficient. The round trip is one HTTP request, but that single request can still fan out into dozens or hundreds of backend calls if resolvers aren't batched — the inefficiency is invisible in a browser's network tab (which shows one request) and only shows up in backend query logs or load testing. The fix is mechanical once known — wrap ID-based lookups in a per-request DataLoader — but the failure mode has to be anticipated, because nothing about writing a naive resolver looks wrong until it's queried at list-scale.
Engineering Lens
The strong answer in a design review that's proposing GraphQL isn't "it lets clients ask for exactly what they need" — every GraphQL pitch says that — it's naming where the N+1 problem will show up in this specific schema (usually: any field that resolves a to-many relationship) and what batching strategy handles it before the first list view ships, rather than discovering it in a production slow-query log after a client starts querying real-sized lists. The same discipline that makes a database schema review ask "what indexes does this need under real query patterns" applies here: a GraphQL schema review should ask "which fields will fan out, and what loads them."