The Problem
A SaaS dashboard I look after went from a 1.4s field First Contentful Paint to a 2.1s FCP over the course of a week. No code changes that should have moved it. Searched the diff and found the change: reactCompiler: true had been flipped on in next.config.ts to let the team drop the manual useMemo and useCallback from a list of components flagged in a refactor.
The Lighthouse trace told the rest of the story. The HTML arrived at the same TTFB it always had. The compiled main bundle had grown by about 18 KB after gzip. More importantly, the first paint event landed about 600ms later than before, and the script execution slice between "First Paint" and "FCP" had stretched from a thin sliver into a fat block.
I ran a next build and diffed the client manifest:
Route (app) Size First Load JS
+ / 14.2 kB 186 kB
○ /dashboard 52.8 kB 244 kB
Against the previous deploy:
Route (app) Size First Load JS
+ / 11.6 kB 168 kB
○ /dashboard 41.4 kB 220 kB
About 18 KB on every route, regardless of whether the route had a single component or thirty. The dashboard had grown the most because it was the route with the heaviest interactive surface.
Why It Happens
The React Compiler does not memoise at compile time the way most people assume. It rewrites your components so each render captures values into a per-component cache and reuses them when the dependency signature matches. The cache lives at runtime. Two things follow from that.
First, the compiled output is bigger. Each previously-bare expression that gets memoised becomes a small read-write against a cache slot. Across a typical dashboard route that adds up. The 18 KB I saw on this project is not unusual for a route with thirty-plus components getting compiled.
Second, on the very first render after hydration, every cache slot is empty. The compiler emits a "build cache" branch and a "use cache" branch for each memo, and the first render always takes the build branch. That branch is heavier than the unmemoised code you used to ship, because it also writes back. After the first render the cached branch dominates and the page is faster. The trade-off is fine for time-to-interactive on heavy SPAs. It is bad for FCP because FCP is the first paint, and the first paint is precisely where you are paying the build-cache tax.
Server components do not get compiled, so the html-side is unchanged. The regression is entirely in the client-component tree that hydrates after the HTML lands. If your app is mostly client components, the regression shows up. If your app has been moved to mostly RSC and pushes interactivity into small islands, you barely notice.
The React Compiler docs describe the runtime cache in the "How it works" section and call out that the first render is heavier. The Next.js opt-in flag does not surface this trade-off in the upgrade notes.
The Fix
You do not turn the compiler off. You move the cost off the FCP path. Three things, in order.
Step 1: Audit which components are still client. Anything that does not call hooks, attach handlers, or read browser-only APIs should be a server component. Each client component you remove from the initial tree is one less compiled component to hydrate before the first paint:
// Before: every list row is client because the parent was.
'use client';
export function PostList({ posts }: { posts: Post[] }) {
return posts.map((p) => <PostRow key={p.id} post={p} />);
}
// After: list and rows are RSC, only the row's button is client.
export function PostList({ posts }: { posts: Post[] }) {
return posts.map((p) => (
<PostRow key={p.id} post={p}>
<LikeButton postId={p.id} />
</PostRow>
));
}
On this dashboard, that one refactor dropped the client bundle for the route by 9 KB and pulled FCP back by about 200ms before any other change.
Step 2: Move heavy memoised work behind a Suspense boundary. The compiler's biggest wins come on components that re-render often with stable props. Those components are also the ones that pay the biggest first-render cost. Push them past the first paint by wrapping their parent in <Suspense> with a tiny fallback that the server can stream immediately:
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<main>
<Header />
<Suspense fallback={<DashboardSkeleton />}>
<DashboardGrid />
</Suspense>
</main>
);
}
The skeleton is a server component with no compiled cache to hydrate, so it paints at FCP. The grid hydrates after, in parallel with whatever data it is waiting on. The compiler's cost moves off the critical path and onto the interactive path, where you are getting the wins back.
Step 3: Trim the compiler runtime with the per-file directive. React 19.2 ships a 'use no memo' directive that turns the compiler off for a single file. Use it on small, stable components where the cache buys nothing and only adds bundle weight:
'use no memo';
export function Divider() {
return <hr className="border-zinc-800" />;
}
The compiler reads the directive, skips the rewrite, and the output for that file shrinks back to its hand-written size. On a dashboard with around forty such components, this knocked another 4 KB off the route bundle. The rule of thumb I use: if the component is one screen of JSX with no hooks, opt it out.
Step 4: Measure with web-vitals in the real RUM stream, not just Lighthouse. Lighthouse runs a fresh cache on a throttled CPU and exaggerates the regression. Real users on warm caches see a smaller hit. Make sure the field data is moving before you ship more fixes:
import { onFCP } from 'web-vitals';
onFCP((metric) => {
navigator.sendBeacon('/rum', JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
path: location.pathname,
}));
});
Watch the p75 over the next 24 hours. On this project the p75 FCP went from 1.4s to 2.1s on the regression, dropped to 1.7s after step one, to 1.5s after step two, and back to 1.4s after step three.
The Lesson
The React Compiler trades a heavier first render for a faster steady state. That trade is fine when most of your route is server components and the compiled client islands are small. When your route is a big client tree, the first render lands on the FCP path and the field score moves the wrong way. Push interactivity into RSC, hide what is left behind a Suspense boundary, and opt out the tiny components that have no hooks to memoise. The compiler stays on, the regression goes away.
If your Core Web Vitals dashboard turned red after a React Compiler rollout and you cannot find the source of the 600ms, that is the exact diagnosis I run for clients. See my services. For a related cache-driven render gotcha on the same release, read Next.js use cache returning stale data.
Field FCP red after a React Compiler rollout? Let me find the 600ms.
