Skip to main content

N+1 at the gateway — DataLoader won't save you

Everyone knows the resolver-level N+1. Federation adds a second, nastier species: the query-plan N+1, where the gateway itself fans out.

The gotcha

A query like "50 orders, each with its customer's loyalty tier" produces a query plan that:

  1. fetches 50 orders from the orders subgraph,
  2. calls _entities on the customers subgraph with 50 representations.

So far so good — that's one batched call. But add one more hop ("each customer's tier benefits from the loyalty subgraph") and depending on your plan, key design and list nesting, you can get per-item entity calls, sequential fetch groups, or a plan that re-fetches the same entity repeatedly across groups.

The failure mode: p50 looks fine, p95 explodes on list-heavy pages, and every subgraph team's dashboard is green because each individual call is fast. The latency lives between the services, in the plan.

How to see it

  • Read query plans for your top-20 operations. Your router can print them (EXPLAIN for your graph). Most teams operating federation for years have never looked at one.
  • Count fetch groups and sequential depth, not resolver timings. Plan depth ≈ your latency floor: 4 sequential groups × 40ms = 160ms before any work happens.
  • Trace with the router's federated tracing enabled — the waterfall makes fan-out visible instantly.

How to fix it

1. Batch-friendly reference resolvers. _entities hands you representations in batches — resolve them with one query, not a loop:

// ❌ one DB hit per representation
__resolveReference(ref) { return db.customer.find(ref.id) }

// ✅ the batch is right there — use it
// (framework-dependent: resolve the full _entities list in one call)

2. Shorten the plan, not the resolvers. Move hot, stable fields to the parent's subgraph via denormalization + @provides (with a real sync story — see composition failures), or reshape keys so the router can reach the leaf in one hop.

3. Cache entities at the router. Entity caching for slow-moving data (product info, user profiles) collapses whole fetch groups. TTLs + event-driven invalidation beats "no caching because correctness" — you're already serving stale data in every CDN'd page you own.

4. Cap list depth in the schema. orders(first: 50) { customer { orders(first: 50) { customer ... }}} is a plan bomb. Depth/complexity limits at the router are a production requirement, not a nice-to-have.

warning

If your federation migration "made everything slower", this is almost always why. The monolith did the joins in-process; the graph does them over the network. The fix is plan engineering — not rolling back federation.