PubTrivia has had this in its Next config since the first week:
const nextConfig = { // Enable React Compiler for automatic memoization reactCompiler: true, ... One line, a devDependency on babel-plugin-react-compiler, and then nobody ever looked at what it did. The whole pitch of the compiler is that you stop thinking about memoisation, so not thinking about it felt like compliance. This week I went and audited it from both ends: the output in the deployed JavaScript, and the compiler's own log over the source.
What is in the shipped bundle
Compiled output is easy to spot. A compiled function opens with a call into the compiler runtime and compares each cached slot against a sentinel:
function a(e){ let i,n,a=(0,r.c)(6),{size:s,label:l,className:o,children:c}=e; return a[0]===Symbol.for("react.memo_cache_sentinel")?(i={overflow:"visible"},a[0]=i):i=a[0], a[1]!==c||a[2]!==o ? ... : ... } (0,r.c)(6) asks for a six-slot cache. So I pulled every chunk the 79 public pages reference and counted:
$ grep -ohE '/_next/static/chunks/[^"?]+\.js' html/*.html | sort -u | wc -l 32 | site-wide, in the browser bundles | |
|---|---|
| distinct JS chunks | 32 |
| compiled functions | 97 |
| memo cache slots requested | 1,611 |
react.memo_cache_sentinel expressions | 153 |
Per page it is strikingly uniform, because 71 of our 79 pages are the same shell around content from one registry:
| page group | compiled functions | slots |
|---|---|---|
| 71 content pages | 13 | 144 |
| 3 legal pages | 3 | 25 |
/faq, /compare, /guides | 13 | 144 |
/pricing | 18 | 167 |
/tools/round-planner | 14 | 207 |
/tools/quiz-night-profit-calculator | 14 | 204 |
/tools/team-name-generator | 14 | 171 |
/tools/qr-code-generator | 13 | 144 |
/tools/scoresheet-generator | 13 | 144 |
| the homepage | 89 | 1,438 |
The three legal pages get three functions because they use a compact footer and skip most of the shell. The homepage gets 89 because it renders the mascots, the demo screens and a particle canvas.
And then two of the five tool pages carry exactly the shell's 13 and not one more. Their components are definitely in there:
$ grep -c 'QrCodeGenerator' ch2/3_jtpuk07hf7r.js 1 $ grep -c 'memo_cache' ch2/3_jtpuk07hf7r.js 0 5,909 bytes of component, zero memoisation. Same for the scoresheet generator at 5,841 bytes. The round planner's chunk, by contrast, contains exactly one cache request, for 63 slots.
Asking the compiler why
The compiler takes a logger option, and nothing in a normal Next build shows you what it says. You can run the Babel plugin yourself in about fifteen lines:
babel.transformSync(code, { filename: f, configFile: false, babelrc: false, parserOpts: { plugins: ['typescript', 'jsx'] }, plugins: [[reactCompiler, { logger: { logEvent(filename, event) { events.push([filename, event]); } }, }]], }); Over all 423 .ts and .tsx files in the repository:
| event | count |
|---|---|
CompileSuccess | 385 functions, in 246 files |
CompileError (a silent bailout) | 25, in 15 files |
| memo slots the compiler allocated | 4,882 |
| memo blocks | 2,129 |
4,882 slots in source against 1,611 in the browser, which reconciles the way you would hope: most of this codebase is server components and gated screens, and the compiler happily compiles things that never ship to a browser at all.
The individual numbers are useful on their own. ContentPage, the one component that renders 67 of our pages, is 57 slots and 19 memo blocks. The header is 17, the footer is 4. Three mascot pieces are 77, 55 and 52, which is why the homepage's figure is six times everything else. The biggest in the repo is a dialog at 127 slots.
Four functions compiled to zero slots: three tiny hooks and the currency context provider. Nothing to cache, no cache allocated, which is the compiler making the right call rather than a failure.
The 25 bailouts, by reason
This is the part worth the evening:
| count | reason the compiler gave |
|---|---|
| 8 | Handle TryStatement with a finalizer ('finally') clause |
| 5 | Handle TryStatement without a catch clause |
| 4 | React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled |
| 2 | Handle Import expressions |
| 2 | Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement |
| 1 | Handle non-variable initialization in ForStatement |
| 1 | Handle empty test in ForStatement |
| 1 | Cannot reassign variable after render completes |
| 1 | Handle UpdateExpression to variables captured within lambdas |
Thirteen of the twenty-five are try. Not the existence of error handling, but two specific shapes of it: a finally block, and a try with no catch. Both are things this codebase does a lot, because the pattern for loading a heavy library on demand looks like this:
setBusy(true) try { const QRCode = (await import("qrcode")).default setDataUrl(await QRCode.toDataURL(target, { width: 512, margin: 2 })) } catch { setError("Could not generate a code for that address.") } finally { setBusy(false) } Three bailouts in one function: the finally, the import() expression, and a second try further down with no catch. That is the whole reason the QR generator ships unmemoised, and the same finally idiom accounts for the admin mailing composer, a download button, and two of the gameplay screens.
The fix is not to delete the finally. It is to move the awaited work into a plain async function outside the component and keep the component's body boring, which is better code anyway. The compiler is not being fussy here, it is telling me which functions are doing non-render work in a render file.
The one that surprised me
components/demo/PhoneQuiz.tsx:187 React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled hooks/use-game-ws.ts:78 (same) An // eslint-disable-next-line react-hooks/exhaustive-deps turns the React Compiler off for the component it sits in. That is a deliberate design decision by the React team and it is defensible: if you have told the linter that you know better than its rules about hook dependencies, the compiler cannot safely assume your dependencies are correct. But the consequence is that the comment you wrote to silence a warning in 2026 quietly removed automatic memoisation from one of the most interactive components on the landing page, and nothing anywhere told you.
We have eight eslint-disable comments in the repository. Four are these. In a codebase whose lint setup does not currently run at all, which I have written about before, a comment addressed to a linter is still changing the compiled output.
What this changes about manual memoisation
The source still has 6 useMemo calls and 49 useCallback calls, and zero React.memo. On a compiled component those are now mostly redundant: the compiler produces the caching, and the hand-written hooks become slots it has to honour as well. I am not ripping them out, because in the bailed-out files they are the only memoisation there is, and the compiler's log is the map of which files those are.
There is also no "use no memo" directive anywhere in the repository, so every one of the 25 bailouts is accidental rather than chosen. That is the actual finding of this audit: not that the compiler is skipping things, but that it is skipping things and succeeding silently, in both directions.
What to take away
If you have reactCompiler: true and have never run the plugin with a logger attached, do that this week. It takes fifteen lines, it finishes in seconds over a few hundred files, and in our case it found that 15 files are not getting the feature we think we turned on.
If you want to poke at the outcome rather than the method, the five free tools are all client components running entirely in your browser with no account and no network calls after load. Three of them are compiled, two of them are not, and the honest answer to "can you tell which" is no, you cannot, which is also worth knowing before you spend a day on bailouts.

