Stepan Samko | Consulting [email protected]

Next.js App Speedup by Fixing Server Fetching & Blocking Waterfalls

An engineering deep dive into diagnosing and repairing data-loading inefficiencies in a modern App Router application.


Summary (TL;DR)

A client migrated their app to the Next.js App Router, and performance tanked. Initial loads were slow, TTFB spiked randomly, and LCP regressed across multiple pages.

The cause wasn’t Next.js — it was the data-loading patterns inside nested server components and layouts: duplicated fetches, blocked rendering waterfalls, oversized JSON, and fetches happening at the wrong layer.

I traced fetches across layouts, route segments, and components, removed blocking await chains, consolidated data loading, applied proper caching, and moved low-priority server work client-side.

The result: 40% faster TTFB, 35% faster LCP, no more waterfalls, and a much smoother loading experience.


Background & Context

The project was a modern Next.js App Router application that should have been fast:

But after migrating from the Pages Router, the app became:

Users experienced:

The issue wasn’t the framework. It was how the App Router server-fetching model was used.


The Actual Scaling Problem

Next.js App Router introduces nested layouts + server components + data-fetching in any layer.

This gives power — but also creates a common trap:

“Every layout or server component can fetch… so many developers fetch everywhere.”

This app had several common performance killers:

Fetching inside multiple nested layouts

Each route had:

app/layout.tsx (fetch)
app/[segment]/layout.tsx (fetch)
app/[segment]/[page]/layout.tsx (fetch)
app/[segment]/[page]/page.tsx (fetch)

If two layouts needed the same data → two separate fetches.

If three segments reused data → three separate fetches.

This created:


Blocking server waterfalls

Inside server components:

const user = await getUser()
const settings = await getSettings(user.id)
const recommendations = await getRecs(settings)

This formed a three-layer sequential chain.

Later:

Rendering couldn’t stream until all awaits resolved.


Fetching non-critical data server-side

Unnecessary things fetched on the server:

These blocked server rendering for absolutely no reason.


Large JSON payloads flooding the server component tree

Certain endpoints returned:

This inflated TTFB and server execution time.


Misuse of fetch caching

Some fetches should’ve been:

Instead, they forced fresh requests on each navigation.


High-Level Solution

I rebuilt the data-loading model into a clean, predictable pipeline:

Move fetches up the tree (root → leaf), never down

Layouts fetch layout data. Pages fetch page data. Client components fetch low-priority data.

Parallelize fetches instead of sequential awaits

Use Promise.all to eliminate waterfalls.

Apply correct caching semantics

Move non-blocking data to client components

Don’t block the server on optional information.

Eliminate repeated/duplicated fetches

Shared layout data is fetched once at the layout layer, not in every component.

Trim and normalize JSON payloads

Return only fields needed for the route.

Use streaming to improve perceived TTFB

With non-critical work deferred, server had fewer “blocking units,” allowing the shell to stream instantly.

The effect was dramatic.


Architecture Overview

flowchart TD
    A[Root Layout: Top-level Server Fetch] --> B[Route Layout: Segment-level Fetch]
    B --> C[Page Component: Minimal Server Fetch]
    C --> STREAM[Streaming RSC Response]

    A --> A2[Root Parallel Fetch Group]
    B --> B2[Segment Parallel Fetch Group]

    A2 --> RSC[RSC Render Tree]
    B2 --> RSC
    C --> RSC
    RSC --> STREAM

    STREAM --> D[Client Components: Optional UI Fetches]

The key idea:

Route-dependent server work runs at the highest valid layer, grouped and parallelized.


Deep Dive: What Was Fixed

Tracing Data Through the Server Tree

I mapped the fetches into a dependency graph:

graph TD
    L1[Root Layout Fetch]
    L2[User Layout Fetch]
    L3[Dashboard Layout Fetch]
    P1[Page Fetch]
    C1[Component Fetch]
    C2[Sidebar Fetch]

    L1 --> L2
    L2 --> L3
    L3 --> P1
    P1 --> C1
    P1 --> C2

In reality, everything fetched everything.

Example discovered issue:

The “user profile” was fetched at:

All four had their own network calls.

Fix: fetch once at root layout, pass down through props.


Sequential Waterfalls → Parallel Fetch Groups

Original:

const a = await fetchA()
const b = await fetchB(a.id)
const c = await fetchC(b.type)

Optimized:

const [a, b, c] = await Promise.all([
  fetchA(),
  fetchB(),
  fetchC(),
])

Then compute the relationships in-memory, not via sequential network calls.

This cut some segments’ load time from 900ms → 180ms.


Correct Layer Caching

Before:

After:

Example:

const data = await fetch(url, { cache: "force-cache" })

And for dynamic pages:

export const dynamic = 'force-dynamic'

or

export const revalidate = 60

This removed re-fetching overhead during navigation.


Moving Non-Critical Fetches Client-Side

Examples moved client-side:

These became:

"use client"
import useSWR from 'swr'

They no longer block server rendering.


Reducing JSON Weight

Some endpoints returned full objects like:

{
  "user": {...},
  "posts": [...],
  "settings": {...},
  "history": [...],
  "analytics": {...}
}

UI only needed:

Lighter JSON = faster TTFB = faster streaming.


Representative Pseudocode of Fixes

Fix 1: Consolidated Fetch Layer

Before (spread everywhere):

// app/layout.tsx
await getUser()

// app/dashboard/layout.tsx
await getUser()

// components/UserBar.tsx
await getUser()

After (fetch once at root):

// app/layout.tsx
export default async function RootLayout({ children }) {
  const user = await getUser()

  return (
    <html>
      <body>
        <UserContextProvider value={user}>
          {children}
        </UserContextProvider>
      </body>
    </html>
  )
}

Fix 2: Parallel Fetching

Before:

const details = await fetchDetails()
const settings = await fetchSettings(details.id)
const stats = await fetchStats(settings.region)

After:

const [details, settings, stats] = await Promise.all([
  fetchDetails(),
  fetchSettings(),
  fetchStats(),
])

Fix 3: Move Non-Blocking Items Client-Side

"use client"

export function SidebarRecommendations() {
  const { data } = useSWR("/api/recs")
  return <Sidebar data={data} />
}

Fix 4: Reduce JSON Payload

// Server: instead of returning large object
return {
  username,
  role,
  theme,
}

Small, predictable, cacheable.


The Impact

Quantitative:

Qualitative:

Users stopped saying:

“Next.js feels slow.”

Instead:

“Wow, routes load instantly now.”


Why This Matters

Most App Router apps have hidden server waterfalls and duplicated fetches. These issues aren’t bugs — they’re misuse of the data-loading model.

Fixing them yields huge improvements with minimal code changes.

For performance engineering, this is the highest ROI category:

The result is real, measurable, user-facing speed.


Key Principles Learned

1. Fetch data at the highest valid layer.

Layouts fetch layout data. Pages fetch page data.

2. Avoid sequential awaits.

Parallelize everything possible.

3. Move optional UI data to the client.

Don’t block server rendering on low-value info.

4. Use correct fetch caching.

Prevent repeated fetches across segment boundaries.

5. Keep server payloads lean.

Lighter JSON = lower TTFB = faster streaming.

6. Streaming is only fast when nothing blocks it.

Server waterfalls kill React Suspense benefits.


Final Thoughts

This project reinforced a key truth about modern SSR:

Performance depends far more on how you fetch data than on the framework you use.

Next.js App Router is extremely fast when used correctly – but very slow when used like classic SSR.

By fixing the data pipeline, eliminating waterfalls, and structuring fetching with intention, the app instantly jumped in Core Web Vitals and user experience.

This is the kind of high-leverage web performance work that creates immediate ROI for real applications.