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:
- fetches 50 orders from the
orderssubgraph, - calls
_entitieson thecustomerssubgraph 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 (
EXPLAINfor 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.
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.