Zero extensions, CPU throttling enabled (4x or 6x slowdown), network throttling disabled. Use this for Lighthouse audits and manual performance tracing.
3. Baseline Profile: Your standard daily browser. Use for real-world validation.
Always audit production builds. Run your build command, serve the output locally, and open the profiling profile. Before opening a bundle analyzer, inspect the Lighthouse Tree Map. It visualizes which dependencies inflate specific chunks, allowing you to identify heavy third-party packages before diving into granular analysis.
Phase 2: Leverage the React Compiler with State Colocation
The React Compiler automatically memoizes components and hooks based on static analysis. You no longer need to wrap callbacks or derived values manually. However, the compiler cannot restructure your component hierarchy. If state lives at the root and a deeply nested input triggers updates, the runtime must evaluate every component between the state source and the consumer on each keystroke.
Architecture Decision: Colocate state as close to the consuming component as possible. Let the compiler handle caching; you handle topology.
// Instead of hoisting form state to a layout wrapper
import { useState } from "react";
export function SearchFilter({ onSearch }) {
const [query, setQuery] = useState("");
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
onSearch(value);
};
return (
<input
type="text"
value={query}
onChange={handleChange}
placeholder="Filter results..."
/>
);
}
The compiler will automatically memoize handleChange and the input render. By keeping query local, you prevent unnecessary re-evaluations of sibling or parent components.
Phase 3: Optimize RSC Payload Boundaries
React Server Components serialize props to the client. If you pass a full database record, every field gets stringified, transmitted, and parsed, even if the client only uses three properties. This bloats the RSC payload and increases hydration time.
Architecture Decision: Pass only the fields required by the client component. Push the "use client" directive as deep as possible to minimize the boundary surface area.
// Server Component (App Router / RSC)
import { db } from "@/lib/database";
import { ClientInventoryCard } from "./ClientInventoryCard";
export async function WarehouseView({ itemId }) {
const record = await db.inventory.findById(itemId);
return (
<section className="warehouse-grid">
<header>Inventory Status</header>
<ClientInventoryCard
sku={record.sku}
stockLevel={record.quantity}
lastUpdated={record.updatedAt}
/>
</section>
);
}
By explicitly destructuring and passing only sku, stockLevel, and lastUpdated, you reduce the serialized payload by 60-80% compared to passing the entire record object.
Phase 4: Implement Optimistic Interactions and Async Rendering
Traditional form handling blocks the UI until the server responds, directly hurting INP. React 19 introduces useOptimistic and useActionState to decouple interaction latency from network round-trips. The UI updates immediately, and the server call runs in the background.
For data fetching, the use() primitive allows you to read Promises or Context directly inside the render phase. Unlike hooks, use() can be called conditionally and integrates seamlessly with Suspense boundaries.
"use client";
import { useOptimistic, useActionState } from "react";
import { reserveSeat } from "@/actions/reservations";
export function BookingPanel({ eventId }) {
const [optimisticCount, addOptimistic] = useOptimistic(0, (current, next) => current + next);
const [state, submitAction, isPending] = useActionState(reserveSeat, { success: false });
return (
<form action={async (formData) => {
addOptimistic(1);
await submitAction(formData);
}}>
<input type="hidden" name="eventId" value={eventId} />
<button type="submit" disabled={isPending}>
{isPending ? "Reserving..." : "Reserve Seat"}
</button>
<p>Confirmed: {optimisticCount}</p>
</form>
);
}
For async data consumption:
import { use, Suspense } from "react";
function UserProfile({ userPromise }) {
const user = use(userPromise);
return <div className="profile-card">{user.displayName}</div>;
}
export default function TeamDirectory({ fetchUser }) {
return (
<Suspense fallback={<div className="skeleton-loader" />}>
<UserProfile userPromise={fetchUser()} />
</Suspense>
);
}
The parent component remains unaware of the Promise lifecycle. Suspense catches the suspension, preventing waterfall requests and keeping the main thread unblocked.
Pitfall Guide
1. Profiling in Development Mode
Explanation: Development builds include hot module replacement, warning checks, and unminified code. These add significant overhead that masks real-world render times and bundle sizes.
Fix: Always run a production build (npm run build), serve it locally, and audit using the clean profiling profile.
2. Assuming the Compiler Fixes State Topology
Explanation: The React Compiler optimizes memoization, but it cannot prevent unnecessary re-renders caused by state hoisted too high in the tree. Every keystroke or toggle will still traverse the component hierarchy.
Fix: Colocate state near its consumers. Use context only for truly global data, and split contexts by update frequency.
3. Serializing Full Database Records to Client Components
Explanation: RSC payloads serialize every property passed to a client boundary. Passing a 40-field record when only three are used wastes bandwidth and increases hydration time.
Fix: Explicitly destructure and pass only the required fields. Treat the RSC boundary like an API contract.
4. Neglecting Explicit Asset Dimensions
Explanation: Images without width and height attributes cause the browser to reflow layout once the asset loads, directly impacting CLS.
Fix: Always specify dimensions. Use loading skeletons that match the final layout geometry instead of spinners.
5. Misapplying use() Outside Suspense Boundaries
Explanation: The use() primitive suspends rendering when a Promise is pending. If not wrapped in a Suspense boundary, it throws an unhandled error and crashes the component tree.
Fix: Always wrap components using use() in <Suspense fallback={...}>. Keep boundaries granular to prevent full-page fallbacks.
6. Over-Memoizing Manually Post-Compiler
Explanation: Adding useMemo and useCallback alongside the React Compiler creates redundant caching layers and increases bundle size without performance gains.
Fix: Remove manual memoization hooks. Trust the compiler's static analysis. Only use manual hooks for values that intentionally break compiler tracking (e.g., external library functions).
7. Ignoring the Lighthouse Tree Map
Explanation: Developers often jump straight to bundle analyzers, which show raw file sizes but not how dependencies map to actual chunks or routes.
Fix: Start with the Lighthouse Tree Map to identify which dependencies inflate specific routes. Use bundle analyzers only for granular chunk optimization.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-interaction dashboard (tables, filters, real-time updates) | React Compiler + State Colocation + Optimistic UI | Reduces INP by eliminating main-thread blocking and unnecessary re-renders | Low (Compiler is free; optimistic UI reduces perceived latency) |
| Content-heavy marketing site | RSC Streaming + Explicit Asset Dimensions + Skeletons | Maximizes LCP and CLS stability by streaming static layout and reserving space | Low (Native RSC support; no extra libraries) |
| E-commerce product page with heavy media | React.lazy + Suspense + Field-Level RSC Props | Defers heavy component loading and minimizes RSC payload serialization | Medium (Requires careful chunk splitting and prop scoping) |
| Legacy app migrating to React 19 | Incremental Compiler adoption + Profile cleanup + Bundlephobia audit | Prevents regression while modernizing build pipeline and profiling workflow | Low-Medium (Audit time required; compiler adoption is non-breaking) |
Configuration Template
// next.config.js / vite.config.js (React Compiler + Bundle Analysis)
const nextConfig = {
reactStrictMode: true,
compiler: {
reactCompiler: true,
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.optimization.splitChunks = {
chunks: "all",
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
priority: 10,
},
framework: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: "framework",
priority: 20,
},
},
};
}
return config;
},
};
module.exports = nextConfig;
// .vscode/settings.json (Profiling Profile Automation)
{
"launch": {
"configurations": [
{
"name": "Profile Production Build",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}",
"runtimeArgs": [
"--disable-extensions",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows"
]
}
]
}
}
Quick Start Guide
- Build & Serve: Run
npm run build && npx serve out (or your framework's equivalent) to host a production bundle locally.
- Launch Clean Profile: Open your zero-extension profiling browser profile and navigate to
http://localhost:3000.
- Run Lighthouse: Execute an audit with 4x CPU throttling. Inspect the Tree Map to identify heavy dependencies before optimizing code.
- Apply Compiler & Colocate State: Enable the React Compiler in your config. Move any hoisted state down to the components that actually consume it.
- Validate INP: Interact with the page repeatedly. Check that INP stays below 200ms and that no single interaction spikes above 500ms. Iterate on payload scoping and Suspense boundaries if needed.