May 2026 · Performance · ~11 min read
Why Your Next.js 15 App is Still Slow (And How to Fix the React 19 Hydration Lag)
By Safdar Ali — frontend engineer, Bengaluru
A team upgraded to Next.js 15 and React 19, turned on the React Compiler, and expected Lighthouse to green itself. It did not. LCP stayed above 3s on mobile; INP spiked whenever users clicked filters on a data-heavy dashboard. The stack was modern — the vitals were not.
I debug these apps weekly in Bengaluru. The pattern is always the same: the upgrade fixed compile-time warnings but not runtime work — hydration still runs on the main thread, server-rendered HTML still shifts when client components mount, and the Compiler does not rewrite every closure. This post is the workflow I used on that dashboard: Chrome DevTools Performance + React Profiler, not another generic checklist.
The upgrade myth
Next.js 15 ships faster defaults — improved caching, stable App Router APIs, React 19 as the peer. React 19 adds the Compiler (optional), use(), and cleaner hydration boundaries. None of that removes:
- Layout shift when a server-rendered skeleton is replaced by a client chart with different dimensions
- Long tasks during hydration of large client trees marked with
"use client" - Event handlers attached late because hydration finished after the user already tapped a button (bad INP)
- Stable object references the Compiler cannot prove — still causing child re-renders
If you only read release notes, you miss the gap between "compiles" and "feels fast."
Before: what the field data showed
| Metric | Before fix | Target |
|---|---|---|
| LCP (mobile, 4G) | 3.8s | < 2.5s |
| CLS | 0.18 | < 0.1 |
| INP (filter click) | 420ms | < 200ms |
| Client JS (dashboard route) | 312 KB gzip | < 180 KB |
Lighthouse lab score was 71 — misleadingly okay — while CrUX-style field data hurt SEO on mobile. That is why I start with Performance panel → Web Vitals and the Layout Shift overlay, not Lighthouse alone.
Fix 1: server layout shifts killing LCP
The LCP element was a hero stat card. Server HTML rendered a fixed-height placeholder; the client DashboardChart mounted with Recharts and resized the card by 48px. CLS 0.18, LCP delayed because the largest paint moved.
In Chrome DevTools → Performance → record page load → enable Experience → Layout Shift Regions. You will see purple overlays on the exact nodes that moved. On this app, the shift correlated with hydration of the chart wrapper, not the image (there was no hero image — the chart was LCP).
What we changed
- Reserved height on the server shell:
min-h-[280px]on the chart container in the Server Component layout - Moved chart data fetch to the server; passed serializable props only — no client-side
useEffectfetch - Lazy-loaded Recharts with
next/dynamicandssr: falseinside the reserved box so LCP stays the static shell
// app/dashboard/page.tsx — Server Component shell
export default async function DashboardPage() {
const stats = await getDashboardStats(); // server fetch, cached
return (
<section className="min-h-[280px] rounded-xl border p-4">
<h1 className="text-lg font-bold">{stats.title}</h1>
<p className="text-3xl font-extrabold tabular-nums">{stats.primaryValue}</p>
<ChartSlot data={stats.series} /> {/* client island, fixed box */}
</section>
);
}LCP: 3.8s → 2.1s. CLS: 0.18 → 0.04. Same React 19 version — no Compiler change required for this fix.
Fix 2: INP and React 19 hydration lag
INP measures responsiveness until the next paint after interaction. On App Router apps, the classic failure: user clicks a filter chip before hydration completes; React queues the update behind a 200ms+ hydration task; INP blows past 300ms.
React Profiler (Chrome → Components → ⚛ Profiler → record → click filter) showed DashboardFilters re-rendering the entire 40-row table because context lived in the same client boundary as the table. Hydration had to finish the whole subtree before handlers felt instant.
Split boundaries
// Before: one "use client" file — filters + table + pagination
// After: three islands
// 1. FilterBar.tsx — small, hydrates first
// 2. DataTable.tsx — dynamic import, ssr: false OR streaming
// 3. page.tsx — Server Component passes searchParams to bothFor Next.js App Router INP optimization, I also defer non-critical client JS with requestIdleCallback polyfill pattern for analytics and moved filter state to URL searchParams so the server can render the filtered view on navigation — less client work on first interaction.
INP: 420ms → 168ms on the filter interaction (median of 20 runs, Moto G4 throttling in DevTools).
Fix 3: what the React Compiler did not catch
We enabled react-compiler in Next.js 15. It removed manual useMemo on simple derived values. It did not fix this pattern — an edge case I still see in code review:
"use client";
function RowActions({ rowId, onArchive }: Props) {
// Compiler skipped memo here: config closes over mutable module-level cache
const config = getActionConfig(rowId); // returns new object when cache miss
const handlers = {
archive: () => onArchive(rowId),
export: () => exportRow(rowId, config.format),
};
return <ActionMenu handlers={handlers} />; // re-renders every parent tick
}
// Module-level cache — reference identity changes across rows
let cache: Record<string, ActionConfig> = {};
function getActionConfig(id: string) {
if (!cache[id]) cache[id] = { format: "csv", label: `Row ${id}` };
return cache[id];
}Profiler flame chart: parent state update → every RowActions child re-rendered because handlers was a fresh object literal every time — the Compiler cannot hoist it when getActionConfig reads mutable external state.
Manual fix (still needed in 2026 for React Compiler debugging):
const handlers = useMemo(
() => ({
archive: () => onArchive(rowId),
export: () => exportRow(rowId, config.format),
}),
[rowId, onArchive, config.format]
);Rule I use: if a helper reads module scope or refs, assume the Compiler will not save you — verify in Profiler before deleting useCallback / useMemo.
React 19 hydration mismatches (bonus)
Search traffic for React 19 hydration error fix often points to date/time and browser-only APIs. On this project we had:
// ❌ Server: "May 31, 2026" Client: "31/05/2026" — locale mismatch
<p>{new Date(ts).toLocaleDateString()}</p>
// ✅ Pass formatted string from server OR suppress with suppressHydrationWarning on static clock
<p suppressHydrationWarning>{formattedDate}</p>Hydration retries and double renders hurt INP more than the console warning suggests. Fix the mismatch before tuning bundles.
Production checklist (what I run in order)
- Performance trace — load + click; mark layout shifts and long tasks (> 50ms)
- React Profiler — find re-renders the Compiler missed (module refs, unstable props)
- Split client islands — smallest interactive shell hydrates first (RSC vs client guide)
- Reserve LCP geometry on the server — height/width before client charts or fonts swap
- Measure field-style — 4G + 4× CPU; lab Lighthouse is a sanity check only
After metrics
| Metric | After |
|---|---|
| LCP | 2.1s |
| CLS | 0.04 |
| INP | 168ms |
| Client JS | 174 KB gzip |
Deeper App Router migration patterns (caching, images, fonts) are in my 60% load-time case study. React 19 feature adoption — production guide here.
Closing
Next.js 15 performance optimization is not a version bump — it is boundary design, fixed layout geometry, and proving Compiler wins in Profiler before you delete memos. The React 19 hydration lag you feel on first click is almost always too much client tree hydrating at once. Shrink the island; the vitals follow.
If this helped you
I publish free tutorials and write-ups like this in my spare time — no paywall on the guides. If it saved you an afternoon of trial and error, you can support the work:
- Buy me a coffee at buymeacoffee.com/safdarali
- Subscribe to my YouTube channel — it's free; 70+ React & Next.js tutorials
Related reading
More guides on safdarali.in — same author, production-focused.
- Guide
How to Build a Frontend Developer Portfolio That Stands Out
Frontend developer portfolio guide for India — sections, React/Next.js examples, SEO, performance, personal branding, FAQ, and checklist to build and rank.
May 2026Read article →
- Workflow
How I Use Cursor + Claude to Ship React Code 3x Faster
Not generic AI hype — Safdar Ali's Cursor AI React workflow: Agent mode, production review checklist, and how I ship Next.js 3× faster without slop.
May 2026Read article →