A team upgraded to Next.js 15 and React 19, enabled the React Compiler, and expected Lighthouse scores to magically improve.
They didn't.
LCP remained above 3 seconds on mobile.
INP spiked whenever users interacted with filters.
Hydration still felt sluggish.
The stack was modern.
The performance wasn't.
After debugging multiple production applications, I've noticed the same pattern repeatedly:
Upgrading frameworks doesn't automatically eliminate runtime bottlenecks.
Hydration still runs on the main thread.
Client components still execute JavaScript.
Layout shifts still hurt Core Web Vitals.
Here's how I diagnosed and fixed these issues.
The Upgrade Myth
Many developers assume that upgrading to the latest stack automatically solves performance problems.
While Next.js 15 and React 19 bring significant improvements, they don't magically fix:
- Layout shifts during hydration
- Excessive client-side JavaScript
- Large client component trees
- Unnecessary re-renders
- Delayed event handlers
The gap between:
"Build succeeds"
and
"Application feels fast"
is where most performance issues live.
Before Optimization
Real-World Metrics
| Metric | Before | Target |
|---|---|---|
| LCP | 3.8s | < 2.5s |
| CLS | 0.18 | < 0.1 |
| INP | 420ms | < 200ms |
| Client JS | 312 KB | < 180 KB |
Lighthouse looked acceptable.
Actual user experience did not.
That's why I always start with:
- Chrome Performance Panel
- React Profiler
- Layout Shift Overlay
- Web Vitals
instead of Lighthouse alone.
Fix #1: Eliminate Layout Shifts During Hydration
The Largest Contentful Paint element was a dashboard chart.
The server rendered a placeholder.
When the client chart mounted, it resized the container and shifted the layout.
Result:
- CLS increased
- LCP was delayed
Diagnosis
Open:
Chrome DevTools → Performance → Record
Enable:
Experience → Layout Shift Regions
The purple overlays immediately revealed the culprit.
Solution
Reserve space before hydration.
export default async function DashboardPage() {
const stats = await getDashboardStats();
return (
<section className="min-h-[280px] rounded-xl border p-4">
<h1>{stats.title}</h1>
<ChartSlot data={stats.series} />
</section>
);
}
Additional improvements:
- Fetch chart data on the server
- Pass serializable props only
- Lazy load chart libraries
- Keep container dimensions fixed
Results
| Metric | Before | After |
|---|---|---|
| LCP | 3.8s | 2.1s |
| CLS | 0.18 | 0.04 |
Fix #2: React 19 Hydration Lag and Poor INP
INP measures how quickly the UI responds after interaction.
A common App Router issue:
- User clicks a filter
- Hydration is still running
- React delays the interaction
- INP spikes
The Problem
Originally:
Dashboard
├── Filters
├── Table
└── Pagination
Everything lived inside one large client component.
Hydration had to complete the entire tree before interactions felt responsive.
The Solution
Split hydration boundaries.
Before
"use client";
// filters
// table
// pagination
After
FilterBar.tsx
DataTable.tsx
page.tsx
The idea:
- Hydrate filters first
- Stream table later
- Keep interactive components small
Additional optimization:
- Move state into URL search params
- Render filtered results on the server
- Defer analytics using idle callbacks
Results
| Metric | Before | After |
|---|---|---|
| INP | 420ms | 168ms |
Fix #3: What React Compiler Doesn't Fix
Many developers believe React Compiler removes the need for optimization.
Not always.
Consider:
const handlers = {
archive: () => onArchive(rowId),
export: () => exportRow(rowId),
};
A new object gets created every render.
React Compiler may not optimize this if:
- Mutable module state exists
- External references are involved
- Reference stability cannot be guaranteed
Manual Optimization Still Matters
const handlers = useMemo(
() => ({
archive: () => onArchive(rowId),
export: () => exportRow(rowId),
}),
[rowId, onArchive]
);
Rule of Thumb
If your helper:
- Reads module scope
- Uses mutable state
- Depends on refs
Verify in React Profiler before removing memoization.
React 19 Hydration Mismatch Example
Another issue I frequently encounter:
<p>{new Date(ts).toLocaleDateString()}</p>
Server output:
May 31, 2026
Client output:
31/05/2026
Hydration mismatch.
Better Approach
<p suppressHydrationWarning>
{formattedDate}
</p>
Or format the value on the server before rendering.
Hydration retries create additional work and can negatively impact INP.
Production Optimization Checklist
My workflow:
1. Record a Performance Trace
Check:
- Long Tasks
- Layout Shifts
- Main Thread Blocking
2. Use React Profiler
Identify:
- Unnecessary re-renders
- Large hydration boundaries
- Expensive components
3. Split Client Islands
Hydrate:
- Search
- Filters
- Menus
before heavier components.
4. Reserve Layout Space
Prevent:
- Chart jumps
- Image shifts
- Font reflows
5. Test Like Real Users
Use:
- 4G throttling
- CPU throttling
- Mobile viewport
Don't trust desktop Lighthouse alone.
Final Metrics
| Metric | Before | After |
|---|---|---|
| LCP | 3.8s | 2.1s |
| CLS | 0.18 | 0.04 |
| INP | 420ms | 168ms |
| Client JS | 312 KB | 174 KB |
Final Thoughts
Next.js 15 performance optimization isn't a version upgrade.
It's:
- Better hydration boundaries
- Smaller client components
- Stable layouts
- Reduced JavaScript
- Measured improvements
React 19 is powerful.
But if your application still hydrates a massive client tree on page load, users will feel it.
The fastest apps aren't the ones running the newest framework versions.
They're the ones doing the least work.
☕ Enjoyed This Article?
If this article saved you hours of debugging:
👉 Buy me a coffee: https://buymeacoffee.com/safdarali
👉 Subscribe to my YouTube channel: https://www.youtube.com/@safdarali_?sub_confirmation=1
I regularly publish content on:
- React
- Next.js
- Frontend Performance
- AI Developer Workflows
- Modern Web Engineering
Thanks for reading! 🚀
Top comments (0)