GraphQL's N+1 Problem: The One It Hides and the One It Creates
GraphQL promises to fix over-fetching. What it doesn't tell you is that it ships its own N+1 problem in the box.
GraphQL will tell you it solves the N+1 problem. It doesn't. It trades the REST version for a worse one, wraps it in a type system, and ships it to production while you're still feeling good about your schema design. I've been burned by this on two separate projects now, and I want to save you the 2 a.m. Datadog alert.
The Problem GraphQL Is Actually Selling You
The REST version of N+1 is familiar: you hit /orders, get back 50 records, then your frontend makes 50 more calls to /orders/{id}/customer because the list endpoint didn't include customer data. That's N+1 HTTP requests, and it's miserable on mobile or over a slow connection.
GraphQL's pitch is clean. The client asks for exactly what it needs in one request:
query {
orders {
id
total
customer {
name
email
}
}
}
One request. The client specifies the shape. No over-fetching, no under-fetching. It's a genuinely good idea. The problem is in how that query gets resolved on your server.
The N+1 GraphQL Creates on Your Backend
Here's what actually happens when that query runs against a naive resolver implementation. In Lighthouse (the Laravel GraphQL library I use most), if you define a type like this:
type Order {
id: ID!
total: Float!
customer: Customer! @belongsTo
}
And your resolver just returns Eloquent models:
public function orders(): \Illuminate\Database\Eloquent\Collection
{
return Order::all();
}
GraphQL will call the customer resolver once per order. You get 50 orders, you get 50 individual SELECT * FROM customers WHERE id = ? queries. One query to get the orders, N queries to get the customers. The REST N+1 moved from the network layer to the database layer, and your client has no idea it's happening.
I saw this in production on an e-commerce project. The orders list query was triggering 80+ database hits. The frontend team was thrilled because they were down to one HTTP request. The database server was not thrilled.
The Fix: DataLoader (and What It Actually Does)
The canonical solution is the DataLoader pattern, popularized by Facebook's reference implementation in JavaScript. The idea: instead of resolving each relationship immediately, you batch all the IDs you need, fire one query, then distribute the results.
Lighthouse handles this automatically for Eloquent relationships when you use the right directives, but it's worth understanding what's happening underneath. Here's a simplified version of what a manual DataLoader looks like in PHP:
class CustomerLoader
{
private array $queue = [];
private array $cache = [];
public function load(int $customerId): \Closure
{
$this->queue[] = $customerId;
// Returns a deferred resolver — called after all field resolvers queue their IDs
return function () use ($customerId) {
if (!isset($this->cache[$customerId])) {
$this->dispatch();
}
return $this->cache[$customerId] ?? null;
};
}
private function dispatch(): void
{
$ids = array_unique($this->queue);
$customers = Customer::whereIn('id', $ids)->get()->keyBy('id');
foreach ($customers as $id => $customer) {
$this->cache[$id] = $customer;
}
$this->queue = [];
}
}
In practice with Lighthouse, you lean on @with or @load directives and Lighthouse's built-in batch loading, or you drop into Nuwave\Lighthouse\Execution\DataLoader\BatchLoader for custom relationships. For simple Eloquent @belongsTo and @hasMany, Lighthouse's deferred loading handles it. For anything custom — API-backed relationships, multi-source data, computed fields that hit a third-party service — you're writing it yourself.
Here's what a proper Lighthouse batch loader looks like for a non-Eloquent relationship, say pulling enrichment data from an external service:
namespace App\GraphQL\Loaders;
use Nuwave\Lighthouse\Execution\DataLoader\BatchLoader;
class ProductEnrichmentLoader extends BatchLoader
{
public function resolve(): array
{
$skus = array_keys($this->keys);
// One call to the enrichment API for all SKUs in this batch
$enrichments = app(EnrichmentService::class)->getBatch($skus);
$result = [];
foreach ($enrichments as $sku => $data) {
$result[$sku] = $data;
}
return $result;
}
}
Then your resolver:
public function enrichment(Product $product, array $args, GraphQLContext $context): mixed
{
return ProductEnrichmentLoader::load($product->sku, $context);
}
Lighthouse defers resolution until the end of the request, groups all queued SKUs, fires one batch call. Fifty products, one API call instead of fifty.
The Gotchas That Will Still Bite You
Batching only works within a single request. This sounds obvious but it isn't. If you have a mutation that fires off async jobs that each make their own GraphQL queries internally, DataLoader gives you nothing. Each job is its own execution context.
Fragments and aliases can fool you. If a client queries the same relationship twice under different aliases:
query {
orders {
billingCustomer: customer { name }
shippingCustomer: customer { name }
}
}
Depending on your implementation, that might batch correctly or it might not. Test it. Don't assume.
Persisted queries don't fix this. I've had clients propose persisted queries as a performance solution and they miss the point entirely. Persisted queries save bandwidth and enable CDN caching for read-heavy public endpoints. They do nothing about your database query count.
Depth limiting and complexity analysis are different problems. A well-meaning junior developer on a project I consulted on had configured query depth limiting to 5 and thought that solved performance issues. It didn't. A depth-3 query on a schema with wide types can still trigger thousands of database calls. Depth limiting is a denial-of-service guard, not a performance tool.
Laravel's N+1 detection middleware will save your life. In development, put this in a service provider:
if (app()->isLocal()) {
\Illuminate\Database\Eloquent\Model::preventLazyLoading();
}
This throws an exception when a lazy-loaded relationship fires outside of an eager load. It will immediately surface every N+1 you have. Run it on your GraphQL endpoints in local. Fix everything that throws before it goes anywhere near staging.
When I'd Reach for GraphQL
GraphQL earns its keep when you have multiple clients (web, mobile, third-party integrations) consuming the same data with legitimately different shape requirements. A healthcare project I worked on had a patient data API consumed by a React dashboard, a mobile app, and a partner integration — each needed wildly different subsets of the same underlying records. GraphQL was the right call. One schema, each consumer asks for what it needs, no versioning gymnastics.
I'd also reach for it when the client-side team is strong and disciplined. GraphQL moves complexity from the server to the query. Undisciplined clients write queries that hit every relationship six levels deep and wonder why things are slow. If you have a frontend team that understands what they're asking for, it's powerful. If you don't, you've just given them a footgun with a nicer grip.
When I Wouldn't
For a simple CRUD app serving one client, REST with resource controllers is faster to build, easier to cache at the HTTP layer, and easier to debug when something goes wrong. GraphQL's caching story is genuinely painful — you lose HTTP-level caching by default because everything is a POST to one endpoint. You can work around it with persisted queries and GET requests for reads, but it's extra infrastructure for something REST handles for free.
I also wouldn't reach for it when the team hasn't internalized the DataLoader pattern. I've inherited two GraphQL codebases where nobody knew about batching. Both had N+1 problems that were worse than any REST API I'd ever touched, because at least with REST the queries are obvious. In GraphQL, the database calls are buried in resolvers and you need tooling like Laravel Telescope or a query log to even see them.
If you're running Lighthouse specifically, install the mll-lab/laravel-graphql-devtools package in development. It gives you a query complexity score and timing breakdowns. Use it. The number that comes back will occasionally terrify you.
The Bottom Line
GraphQL didn't eliminate the N+1 problem. It relocated it from HTTP to SQL and made it harder to spot. If you understand DataLoader and you're disciplined about eager loading, GraphQL is a legitimate tool for the right problems. If you're reaching for it because it's modern and your last API had too many endpoints, you're going to have a bad time. Know what you're signing up for before you commit your schema to production.
Need help shipping something like this? Get in touch.