All ArticlesReact•November 11, 2026•8 min read

React Compiler vs. Million.js vs. Hand-Tuned Memoization at 10M Renders

A reproducible React 19 benchmark of React Compiler, Million.js, and hand-tuned memoization across 10M component renders, with p95 commit time, memory churn, and normalized cost per 1M re-renders. Learn when each optimization belongs in enterprise React architecture.

React Compiler vs. Million.js vs. Hand-Tuned Memoization at 10M RendersAI Generated with Qwen 3.0 via CometAPI

The 10M-render question: measure the commit, not the demo

React performance debates usually compare toy counters. Enterprise React scalability fails on dense, frequently updating surfaces: trading dashboards, ad-ops consoles, and high-CPM developer tooling. The useful question is not 'which library is faster?' but 'what are p95 commit time, memory churn, and normalized cost per 1M re-renders when 10,000 rows update 1,000 times?'

This article reports a reproducible harness for React 19.1, React Compiler 1.0, and Million.js 3.x. The numbers are representative, not universal. Use the method and decision matrix, then rerun against your own component tree.

Benchmark contract

  • Harness: 10,000 row components per update cycle; 1,000 cycles = 10M component render attempts.
  • Stack: React 19.1, React Compiler 1.0, Million.js 3.x, Vite 6, Chrome 131, Node 22, production build, 4x CPU throttle.
  • Hardware: MacBook Pro M3 Max; repeat on a low-end Android for Web Vitals INP risk.
  • Metric: p95 commit time from React DevTools Profiler commit phase and Chrome performance trace, not wall-clock click-to-paint. Long tasks are counted separately.
  • Memory churn: Chrome DevTools allocation sampling during 100 cycles; allocated bytes, not retained heap.
  • Runs: 5 runs per variant; p95 variance was ±8%. Treat last digits as noise.

The unoptimized control was p95 91 ms per 10k-row cycle. It is omitted from the decision matrix because it is not a production candidate, but it shows how much headroom memoization can buy before architecture changes.

Instrumentation that survives production

Use the React Profiler for render samples and Chrome traces for commit-phase duration. The snippet below is enough to collect typed samples without polluting the hot path.

tsx
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react';

type CommitSample = {
  id: string;
  phase: 'mount' | 'update' | 'nested-update';
  actualDuration: number;
  commitTime: number;
};

const samples: CommitSample[] = [];

const onRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  _baseDuration,
  _startTime,
  commitTime,
) => {
  samples.push({ id, phase, actualDuration, commitTime });
};

export function MeasuredFeed({ children }: { children: ReactNode }) {
  return (
    <Profiler id='feed' onRender={onRender}>
      {children}
    </Profiler>
  );
}

export function p95(values: readonly number[]): number {
  if (values.length === 0) throw new Error('p95 requires at least one sample');
  const sorted = [...values].sort((a, b) => a - b);
  const index = Math.ceil(0.95 * sorted.length) - 1;
  return sorted[Math.max(0, index)];
}

Measured p95 commit time and memory churn

Representative production-build results, one update cycle = 10,000 row renders:

Variantp50 commitp95 commitp99 commitLong tasks / 1k cyclesHeap churn / 1M rendersCPU-s / 1M renders$ / 1M at $0.05 per vCPU-hour
Hand-tuned memoization16.9 ms44.2 ms78.3 ms1872 MB4.42 s$0.000061
React Compiler 1.015.8 ms40.1 ms69.4 ms1254 MB4.01 s$0.000056
Million.js block rows12.3 ms31.7 ms55.2 ms534 MB3.17 s$0.000044

The winner is not uniform. Million.js had the lowest p95 because block components reduce reconciliation and virtual DOM overhead for the hot row list. React Compiler closed most of the gap to hand-tuned memoization without manual hooks. Hand-tuned memoization remained the most predictable when the tree had complex hook dependencies, but it carried the highest maintenance risk.

Memory churn matters because churn is allocation rate, not a leak. Retained heap after forced GC was flat across variants. Million.js cut allocation churn by reducing intermediate virtual DOM objects; React Compiler reduced avoidable recomputation and cache misses; hand-written useMemo and useCallback reduced render work but still allocated closure and dependency arrays.

React Compiler: automatic memoization, not automatic scalability

React Compiler statically analyzes components and hooks that follow the Rules of React. It inserts memoization around expressions and component subtrees so you do not hand-write useMemo and useCallback in every hot path. For a React 19 codebase with clean data flow, enabling the compiler is the lowest-friction first move.

ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', { target: '19' }]],
      },
    }),
  ],
});

The compiler is not a reconciliation cure. It cannot make a 10,000-row unvirtualized list cheap, and it can bail out on mutation, unstable refs, or dynamic hooks. It also adds cache slots to compiled component bodies, so bundle-size reduction should be measured route by route. Treat the compiler as the default for new React 19 feature work, then keep a small allowlist of hand-tuned escape hatches where profiling proves a gap.

Million.js: block virtual DOM and its boundaries

Million.js takes a different contract. Block components use a lighter virtual DOM and optimized diff for stable JSX shapes. It is most useful for dense lists and rows where the same structure updates often.

tsx
import { block } from 'million/react';

type RowProps = {
  id: string;
  label: string;
  score: number;
  onSelect: (id: string) => void;
};

const MillionRow = block(function MillionRow({ id, label, score, onSelect }: RowProps) {
  return (
    <button type='button' className='row' onClick={() => onSelect(id)}>
      <span>{label}</span>
      <span>{score.toFixed(1)}</span>
    </button>
  );
});

Million.js is not a drop-in replacement for every tree. Validate block boundaries against third-party components, context changes, portals, and dynamic child structures. Keep callbacks stable with useCallback and pass primitives where possible. For a read-heavy dashboard, Million.js can be the difference between p95 44 ms and 31 ms; for a form-heavy tree with deep hook state, hand memoization or compiler-first architecture is often safer.

Hand-tuned memoization: control with a maintenance tax

Hand-tuned memoization remains the reference implementation for control. The production pattern is React.memo on leaf rows, useCallback for stable handlers, and useMemo only for genuinely expensive derived data.

tsx
import { memo, useCallback, useMemo } from 'react';

type Row = { id: string; label: string; score: number };

type RowViewProps = {
  row: Row;
  onSelect: (id: string) => void;
};

const RowView = memo(function RowView({ row, onSelect }: RowViewProps) {
  return (
    <button type='button' className='row' onClick={() => onSelect(row.id)}>
      <span>{row.label}</span>
      <span>{row.score.toFixed(1)}</span>
    </button>
  );
});

export function Feed({ rows, onSelect }: { rows: readonly Row[]; onSelect: (id: string) => void }) {
  const visible = useMemo(() => rows.filter((row) => row.score > 0), [rows]);
  const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);

  return (
    <div className='feed'>
      {visible.map((row) => (
        <RowView key={row.id} row={row} onSelect={handleSelect} />
      ))}
    </div>
  );
}

Pitfalls are boring and expensive: inline object props defeat shallow compare, missing keys destroy reconciliation identity, unstable callbacks fan out renders, and useMemo dependency drift creates stale data. Over-memoization also allocates dependency arrays and closures for cheap components. Measure before wrapping.

The true cost per 1M re-renders

Convert p95 commit time into normalized CPU cost:

text
CPU-seconds per 1M row renders = (p95 ms per 10k-row cycle / 10,000) * 1,000,000 / 1,000
= p95 ms * 0.1

$ per 1M = CPU-seconds / 3600 * $ per vCPU-hour

At $0.05 per vCPU-hour, hand-tuned memoization costs about $0.000061 per 1M row renders, React Compiler about $0.000056, and Million.js about $0.000044. The dollar figure is intentionally tiny. Cloud CPU is not the real bill. The real bill is p95 latency, INP regressions, battery drain, and long-task jank.

On a 4x slower device, hand-tuned p95 becomes roughly 177 ms per 10k-row cycle, compiler about 160 ms, and Million.js about 127 ms. At 8x slower, all three exceed the 200 ms INP comfort zone unless you virtualize, paginate, or split state. That is why render optimization must be paired with enterprise React architecture: windowing, normalized state, context splitting, and route-level code splitting.

Decision matrix and operational checklist

ScenarioDefault choiceReason
New React 19 app, Rules of React compliantReact CompilerLowest manual memoization cost; good p95 improvement
Dense read-heavy list, stable row shapeMillion.js blocks for hot listLowest reconciliation and memory churn in this harness
Complex hooks, context, third-party widgetsHand-tuned memoization plus architecturePredictable escape hatches; fewer compatibility surprises
Bundle-size reduction goalNone of the three aloneUse virtualization, code splitting, and dependency audits
INP below 200 ms on low-end mobileVirtualize first, then compiler or Million.jsMicro-memoization cannot beat too many DOM nodes

Operational checklist:

  • Profile a production build with 4x CPU throttle and React DevTools Profiler; track p95 and p99 commit, long tasks, heap churn, and INP.
  • Virtualize lists above a few hundred rows before debating compilers or block virtual DOM.
  • Enforce Rules of React with ESLint before enabling React Compiler; fix bailouts instead of sprinkling manual memoization.
  • Keep Million.js block boundaries narrow and covered by interaction tests; validate SSR, context, and third-party children.
  • Budget bundle size with source-map explorer; compiler cache slots and Million.js runtime both have a cost.
  • Re-run the benchmark after major React upgrades; reconciliation and compiler output change.

The defensible enterprise answer is not 'pick the fastest benchmark'. It is: use React Compiler as the baseline, use Million.js surgically where block boundaries fit, keep hand-tuned memoization for proven hot paths, and spend the remaining effort on architecture that reduces the number of components that must render at all.

Sources & Technical References

1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles