DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One line in next.config.mjs put 617 memo slots into our JavaScript, and 63 of them are in a tool that adds up minutes

Our next.config.mjs has this near the top:

// Enable React Compiler for automatic memoization
reactCompiler: true,
Enter fullscreen mode Exit fullscreen mode

and babel-plugin-react-compiler sitting in devDependencies. Both have been there for months. Neither tells you whether the compiler is doing anything to the code your visitors download, so I went and counted, from outside, with nothing but curl.

How to check any site, including yours

The compiler's output has a fingerprint. Compiled components allocate a cache array and compare against a sentinel symbol:

const $ = useMemoCache(63);
if ($[0] === Symbol.for("react.memo_cache_sentinel")) { ... }
Enter fullscreen mode Exit fullscreen mode

Minified, useMemoCache becomes a one-letter export, but the symbol name survives verbatim, because it is a string. So:

curl -s https://pub-trivia.app/tools/round-planner \
  | grep -o '/_next/static/chunks/[a-zA-Z0-9._-]*\.js' | sort -u \
  | while read c; do curl -s "https://pub-trivia.app$c"; done \
  | grep -o 'react.memo_cache_sentinel' | wc -l
Enter fullscreen mode Exit fullscreen mode

If that prints zero, the compiler is not in your output, whatever your config says.

What ours prints

I did this across four pages: the homepage, the pricing page, the round planner and the FAQ. Each page loads 19 chunks, 24 distinct across all four.

Measurement Count
Distinct chunks 24
Chunks containing at least one compiled component 8
Memo caches allocated 43
Total cache slots 617
react.memo_cache_sentinel references 81

The distribution of those 43 allocations, by size:

99, 83, 63, 28, 25, 17, 14 x5, 12 x2, 11 x8, 10, 9 x4, 7 x2, 6 x3, 5 x5, 4 x2, 3 x2, 1 x3
Enter fullscreen mode Exit fullscreen mode

Eight components land on exactly 11 slots, which is what a small component with a couple of handlers and a handful of static elements costs. The two large ones, 99 and 83, live in the chunk that only the homepage loads, the one holding our animated landing page demo. Two I can name because the compiler left their names alone: SiteHeader at 17 slots and FooterWordmark at 11, both in the shared layout chunk, so every page on the site pays for those two.

Sixteen of the 24 chunks contain no compiled component at all. That is the honest shape of this site: almost everything is a server component, so most of the JavaScript is framework and Radix primitives from node_modules, which the compiler does not touch.

63 slots for a component that adds up minutes

The biggest one of ours that I can point at in the source is the round planner, a 313-line client component whose entire job is arithmetic. Its chunk is 10,902 bytes and contains 11 sentinel references, and it opens like this:

e.s(["RoundPlanner",0,function(){
  let e,u,p,b,g,x,v,y,j,N,k,w,S,M,_,P,$,C,R,q,I,L,T,z,B,Q,D,F,O,
      A=(0,a.c)(63),
      [U,G]=(0,s.useState)("20:00"),
      [K,E]=(0,s.useState)(15),
      [H,V]=(0,s.useState)(3);
Enter fullscreen mode Exit fullscreen mode

Twenty-nine temporaries and a 63-slot cache, declared before the first hook runs.

The body then uses two shapes. Things with no dependencies get a sentinel check and are computed once for the life of the component:

A[12]===Symbol.for("react.memo_cache_sentinel")
  ? (v=(0,t.jsx)(o.Label,{htmlFor:"start-time",children:"Start time"}), A[12]=v)
  : v=A[12]
Enter fullscreen mode Exit fullscreen mode

That is the <Label>Start time</Label> in my source, now allocated exactly once per mount instead of once per render. The same treatment goes to the initial array of six rounds, and to the update handler, which is a closure I wrote inline and never thought about again:

A[11]===Symbol.for("react.memo_cache_sentinel")
  ? (x=(e,t)=>X(a=>a.map(a=>a.id===e?{...a,...t}:a)), A[11]=x)
  : x=A[11]
Enter fullscreen mode Exit fullscreen mode

Things with dependencies get a comparison against the stored inputs:

A[8]!==p||A[9]!==b ? (g={items:p,total:b}, A[8]=p, A[9]=b, A[10]=g) : g=A[10]
Enter fullscreen mode Exit fullscreen mode

Three slots to express one useMemo: two for the inputs, one for the result. The useMemo I wrote by hand is still in the source, and the compiler has rewritten it into the same mechanism it uses for everything else, which is the part I find most interesting. Hand-written memoisation does not get skipped or doubled. It gets normalised.

Is it worth it

I measured artefacts, not speed, and I want to be careful about the difference. What I can say from the output:

  • The cost is one 63-element array per mount, and one or two comparisons per cached value per render. On a component whose state changes when you type a number into an input, that is nothing.
  • The benefit on this component is also close to nothing, because its children are inputs and list items with no expensive work behind them. The planner would re-render fine without a single memo in it.
  • The benefit I actually wanted is not performance, it is that nobody has to decide. Before the compiler, useCallback on that update handler was a judgement call that gets made differently by whoever is typing, and reviewed by nobody. Now it is a build step with consistent opinions.

The thing worth knowing is the ratio. One config line, 43 compiled components, 617 slots, and 16 of 24 chunks untouched. If your app is mostly server components, the compiler's reach is a lot smaller than the config line suggests, which is an argument for turning it on and forgetting about it rather than an argument against.

Try it on the live page

Everything above is served publicly. The round planner is at pub-trivia.app/tools/round-planner, and the compiled chunk behind it answers to curl:

curl -s https://pub-trivia.app/_next/static/chunks/1owsg2rvp5-1f.js \
  | grep -c 'react.memo_cache_sentinel'
Enter fullscreen mode Exit fullscreen mode

Eleven. Then run the same count against your own build and see whether the config line you added last year is actually in the bytes.

(Chunk names change on every deploy, so pull the current one out of the page's HTML first. If you want the rest of what we ship, it is all on pub-trivia.app, free tier, no card.)

Top comments (0)