Skip to main content

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

MetricBefore fixTarget
LCP (mobile, 4G)3.8s< 2.5s
CLS0.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

  1. Reserved height on the server shell: min-h-[280px] on the chart container in the Server Component layout
  2. Moved chart data fetch to the server; passed serializable props only — no client-side useEffect fetch
  3. Lazy-loaded Recharts with next/dynamic and ssr: false inside 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 both

For 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)

  1. Performance trace — load + click; mark layout shifts and long tasks (> 50ms)
  2. React Profiler — find re-renders the Compiler missed (module refs, unstable props)
  3. Split client islands — smallest interactive shell hydrates first (RSC vs client guide)
  4. Reserve LCP geometry on the server — height/width before client charts or fonts swap
  5. Measure field-style — 4G + 4× CPU; lab Lighthouse is a sanity check only

After metrics

MetricAfter
LCP2.1s
CLS0.04
INP168ms
Client JS174 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:

More guides on safdarali.in — same author, production-focused.

"Talk is cheap. Show me the code."