GraphQL Waterfalls in Next.js App Router
While building internal engineering tools at Hoomanely for visualizing telemetry from our IoT devices, trackers capturing motion and position data, EverBowls measuring weight and behavior signals, EverHubs aggregating sensor data at the edge before cloud upload, all of it flowing through GraphQL APIs into dashboards built on the Next.js App Router, we ran into a performance problem a few months after migrating from the Pages Router.
TTFB was creeping up on pages that should have been fast. GraphQL server logs showed duplicate queries, sometimes the same query firing three or four times per page load. The code looked clean. Components were async, data fetching was straightforward. But something fundamental was broken.
The problem
We were fetching data inside nested Server Components:
async function DashboardPage() {
return (
<>
<DeviceList />
<TelemetryPanel />
<EventLog />
<HealthStatus />
</>
);
}
async function DeviceList() {
const devices = await fetchDevices();
return <List items={devices} />;
}
async function TelemetryPanel() {
const telemetry = await fetchTelemetry();
return <Panel data={telemetry} />;
}This pattern came from Pages Router thinking, where each component owns its own data dependencies. Separation of concerns, component encapsulation, all reasonable on paper. But in App Router, each async Server Component creates a network boundary. React Server Components don't automatically parallelize these calls, they execute in the order the component tree renders. Our four independent queries were running sequentially: fetch devices, wait, fetch telemetry, wait, fetch events, wait.
Worse, our GraphQL client was only deduplicating queries within a single component's execution context. If two components requested the same data, both queries hit the network, since the client's request cache didn't span component boundaries.

The component tree structure was dictating fetch order. This isn't a bug, it's how RSC works. Component composition became request composition.
Why this happens
Pages Router had a clear data-fetching boundary: getServerSideProps or getStaticProps. One function, one place, explicit control over parallelism:
export async function getServerSideProps() {
const [devices, telemetry, events] = await Promise.all([
fetchDevices(),
fetchTelemetry(),
fetchEvents(),
]);
return { props: { devices, telemetry, events } };
}Parallelism was obvious and guaranteed. App Router distributes fetching across the component tree instead, which enables powerful patterns like streaming, partial hydration, granular caching, and progressive rendering. But it requires understanding that if you nest components that fetch, you're nesting network requests. There's no implicit query planner, no automatic batching across component boundaries, no framework magic parallelizing independent async calls in different components. The structure you write is the execution order you get.
Our dashboards were loading device metadata, then telemetry streams, then event logs, then health signals, all sequentially, not because these queries had real data dependencies, but because the components were nested and each contained an async fetch.
What we changed
Root-level query orchestration. We stopped fetching in nested components entirely and moved all data fetching to the root:
async function DashboardPage() {
const [devices, telemetry, events, health] = await Promise.all([
fetchDevices(),
fetchTelemetry(),
fetchEvents(),
fetchHealthStatus(),
]);
return (
<Dashboard
devices={devices}
telemetry={telemetry}
events={events}
health={health}
/>
);
}Single execution scope, explicit parallelism via Promise.all, no hidden waterfalls. Child components became pure, receiving data as props and rendering. This mirrors how we structure embedded systems: data pipelines should be explicit, dependencies visible, execution order shouldn't emerge from component hierarchy.

GraphQL client configuration. We reconfigured for server-side rendering with request-level deduplication, using a BatchHttpLink that batches multiple queries into a single HTTP request:
const serverClient = new Client({
ssrMode: true,
cache: new InMemoryCache(),
link: new BatchHttpLink({
uri: process.env.GRAPHQL_ENDPOINT,
batchMax: 10,
batchInterval: 20,
credentials: 'include',
}),
});If two components query different data in the same execution window, they go out together. The cache deduplicates identical queries across the entire request lifecycle rather than per-component. For pages with many small queries, this dramatically cuts network round trips, ten sequential requests can become one or two batched ones.
Separating data by volatility. Different data changes at different rates, so the caching strategy should match. For stable data like device metadata, firmware versions, and historical aggregates, we use ISR:
export const revalidate = 60;The page regenerates every sixty seconds, expensive aggregation queries run once, and cached results serve many requests. For personalized data like user-specific device lists and permissions, we use SSR with export const dynamic = 'force-dynamic', since these queries resolve quickly and the per-request cost is acceptable. For real-time data like live sensor readings and connection status, we moved to client-side GraphQL with polling or subscriptions, since RSC isn't built for sub-second updates and constantly revalidating ISR for that would create unnecessary load.

A server-side cache layer. We built a cache sitting between Next.js and our GraphQL upstream, hashing the query and variables into a key and checking for a fresh cached response before hitting the resolver. This prevents redundant upstream queries even when Next.js ISR revalidates, and we coordinate cache TTLs with GraphQL schema directives so behavior stays consistent across every consumer of the API, not just Next.js. For distributed deployments, the in-memory map swaps for Redis or a managed key-value store with the same interface.
How we use this in practice
Firmware engineers reviewing device behavior need comprehensive telemetry views, so those pages use root-level orchestration with SSR, fresh data every load, predictable performance. Hardware designers analyzing trends across device fleets need historical aggregates, so those use ISR with longer revalidation windows for expensive queries that join telemetry across thousands of devices. Operations teams monitoring live device status combine ISR for device metadata that rarely changes with client-side GraphQL for connection status and live readings that change constantly. The pattern stays consistent: batch queries at the root, match caching to data characteristics, keep real-time updates client-side.
What changed
TTFB stabilized. Pages that were taking over 800ms to first byte now respond in 150 to 200ms. GraphQL query volume dropped, no more duplicate queries within a request. Cache hit rates improved because we're caching the right things at the right granularity. More importantly, refactoring stopped breaking performance, since moving a component no longer accidentally serializes queries. Parallelism is explicit now: if queries should run concurrently, they're in a Promise.all at the root, and if not, the dependency is visible.
Takeaways
Component nesting becomes request ordering. In App Router, nesting async Server Components that fetch data means nesting network requests, that's not a framework bug, it's how RSC works. Batch at the root using Promise.all for independent queries, since the framework won't magically parallelize across component boundaries. Configure GraphQL properly with batching links and request-scoped deduplication. Match caching to data: ISR for expensive stable data, SSR for fast personalized queries, client-side for real-time updates. And cache above Next.js, since a server-side cache between Next.js and GraphQL prevents redundant resolver hits across ISR revalidations.
App Router is fast when structured correctly, but it demands understanding how component composition affects execution order. The framework won't prevent waterfalls, you have to design around them.