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:
- Server Components
- Streaming Rendering
- React Suspense architecture
- Route Segment Caching
- Layout reuse
- Good hosting environment
But after migrating from the Pages Router, the app became:
- slower to load
- inconsistent across routes
- unstable under network variation
- prone to TTFB spikes
- unable to stream properly because of blocking server work
Users experienced:
- slow first render
- layouts taking “forever” to appear
- route transitions feeling sluggish
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:
- Duplicated requests
- Extra network hops
- Redundant serialization
- Larger server CPU time
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:
- layouts performed their own fetches
- pages repeated their fetches
- each segment caused more waits
Rendering couldn’t stream until all awaits resolved.
Fetching non-critical data server-side
Unnecessary things fetched on the server:
- user avatars
- side navigation suggestion data
- optional metadata
- analytics bootstrap
- user preferences for UI colors
- small non-blocking lists
These blocked server rendering for absolutely no reason.
Large JSON payloads flooding the server component tree
Certain endpoints returned:
- 50+ fields
- embedded nested objects the UI didn’t need
- large lists that were filtered client-side anyway
This inflated TTFB and server execution time.
Misuse of fetch caching
Some fetches should’ve been:
- route-segment cached
- static cached
- or client-side fetched
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
cache: 'force-cache'for stable resources- route-segment caching where valid
- client-side SWR for low-priority UI data
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:
- root layout
- dashboard layout
- page
- a nested server component
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:
- fetches done inside components
- no caching set
- Next.js defaulted to
cache: 'no-store'
After:
- stable metadata cached static
- user data cached per-session
- route data cached per segment
- expensive rare data cached on the server layer
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:
- avatar photos
- sidebar suggestions
- small lists
- personalization settings
- breadcrumb metadata
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:
- username
- role
- 3 settings values
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:
- 40% faster TTFB
- 35% faster LCP
- Server CPU usage dropped
- Waterfalls removed
- Streaming started reliably within tens of milliseconds
Qualitative:
- Routes felt instant
- Layout shell rendered without delays
- Client-perceived responsiveness improved
- Navigation became smooth and predictable
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:
- No redesign
- No refactor
- No backend changes
- No infrastructure cost
- Pure architectural cleanup
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.