IFR Benchmarks v0.5

This page collects the measurement campaigns behind Instant First-Frame Rendering and Element Templates. Each campaign answers one question:

campaignthe question it answers
1. Strategy ladderHow much does the synchronous main-thread render cost, per design — and what is the ceiling?
2. All-examples sweepWhat does IFR cost in size and TTI on real apps, and is it semantically safe? (Also: proof that a single process cannot measure its benefit.)
3. Real threadsWhat does IFR actually win, measured across a genuine thread boundary — with ReactLynx as the control?
3b. Large-app reevaluationDoes the −19% median still hold once TodoMVC / Hacker News / AI Chat / Elk join the matrix?

A note on configuration names. Campaigns 1 and 2 ran before enableIFR defaulted to enabling Element Templates. Columns are labeled with today's product configurations: what the raw reports call ifr is today's IFR without ET (enableElementTemplates: false opt-out), and ifr+et is today's default enableIFR: true. Campaign 3b always sets both flags explicitly. Full methodology and raw data: strategy benchmark, all-examples sweep, real-browser verification, large-app reevaluation.

1. Strategy ladder

Takeaway: Element Templates are the inflection point of render cost. Plain IFR replay costs as much JS as a full render (~8–11 ms per 1000 elements under an interpreter); ET cuts it about 6–15× across reruns; a Vapor-style design would buy another 2–3× and sit at the PAPI floor. This is why ET is enabled by default with IFR: it attacks the one real cost IFR adds to the main thread — the synchronous first-screen render inside loadTemplate.

Seven rendering strategies were prototyped and measured against the same logical first screens through the same Element PAPI surface. All variants produce byte-identical rendered documents. Scenes are ~1000–1400 elements: static-heavy (99.7% template-static), content (card feed, 37%), list (v-for, 12%).

Warm render time, --jitless V8 (interpreter ≈ main-thread engine proxy)

variantstatic-heavycontentlist
bg-baseline (No IFR pipeline)11.86 ms9.36 ms6.87 ms
IFR without ET (shipped)10.94 ms8.40 ms6.57 ms
ifr-direct (prototype)8.17 ms6.36 ms4.89 ms
ifr-static-tpl (prototype)1.11 ms5.29 ms4.00 ms
IFR + ET (shipped, default)0.74 ms1.26 ms1.44 ms
ifr-vapor (prototype upper bound)0.53 ms0.55 ms0.46 ms
papi-floor (reference)0.54 ms0.39 ms0.31 ms

Cold first run, --jitless (models a device's one-shot first frame)

variantstatic-heavycontentlist
IFR without ET18.0 ms16.4 ms11.7 ms
IFR + ET4.2 ms5.3 ms4.9 ms
ifr-vapor1.4 ms1.2 ms0.8 ms

Ops payload that would cross the thread boundary

variantstatic-heavycontentlist
without ET77.6 KB60.4 KB45.1 KB
with ET69 B9.2 KB17.1 KB

The prototypes are kept for reference: ifr-direct was superseded by ET (handle-based application is implied by template instantiation), and the Vapor design remains the tracked endgame — its prototype omits reactive-effect bookkeeping, so read its numbers as an optimistic bound.

2. All-examples sweep

Takeaway: this campaign measures IFR's costs and its semantic safety — not its benefit. Bundle gzip ×2.26 and a TTI upper bound of ×1.36 are the price; 22/23 examples rendering byte-identical documents across all configurations is the safety result. The per-example FCP columns come out flat (median ×1.04) — which is itself a finding: IFR is a dual-thread architecture optimization, and a single-process harness has no background boot or IPC to remove. Read the flat FCP as proof that the JS work is conserved, then see campaign 3 for the actual win.

Every example was built in three configurations and executed as real bundle halves in a PAPI-over-jsdom environment (5 runs each, medians, --jitless, main-thread parse excluded as lepus ships precompiled bytecode).

exampleNo IFRIFR w/o ETΔIFR + ETTTI No IFRTTI IFR w/o ETΔTTInodes
7guis65.663.1−4%64.365.676.0+16%169
basic32.034.3+7%33.732.044.0+38%9
css-features43.350.0+15%44.243.363.3+46%51
gallery66.665.0−2%69.566.689.4+34%4
hackernews-css40.035.4−11%38.940.058.8+47%14
hackernews-tailwind55.953.0−5%52.255.976.1+36%20
hello-world33.535.6+6%36.033.545.7+36%16
keep-alive47.744.5−7%46.547.757.0+19%56
main-thread26.724.3−9%24.526.733.0+24%3
networking24.930.5+23%35.124.930.5+23%0
option-api53.857.1+6%59.753.868.8+28%9
pinia40.138.7−4%39.440.150.0+25%19
provide-inject32.234.3+6%37.932.243.7+36%13
reactivity33.636.1+7%36.033.646.1+37%19
slots40.143.8+9%39.040.154.3+35%47
suspense42.338.4−9%38.342.351.1+21%16
swiper30.328.5−6%29.830.341.1+36%47
tailwindcss22.525.8+15%20.922.536.9+64%71
todomvc17.921.6+21%20.717.931.4+76%11
todomvc-day120.420.1−2%15.920.429.4+44%7
transition45.945.2−1%47.745.959.7+30%56
v-model39.140.9+4%40.739.152.3+34%26
vue-router43.245.4+5%45.043.260.3+40%6

(FCP/TTI in ms. networking renders 0 nodes at first frame — fetch-driven, the documented "don't enable IFR" profile. todomvc-codex was thread-incompatible at measurement time and has since been fixed at the example level.)

Bundle size (gzip, KiB) — the headline cost

exampleNo IFRwith IFRΔ
hello-world34.576.3+121%
gallery37.280.0+115%
hackernews-css72.6179.1+147%
networking57.5145.5+153%
tailwindcss36.279.0+118%
median (all examples)×2.26

The main-thread bundle section grows from ~17 KiB (worklet registrations only) to 78–195 KiB (Vue runtime + app copy); ET adds ~1% on top. The sweep also surfaced and fixed three real bugs (SystemInfo clobbering, numeric-style baking bypassing auto-px normalization, CSS Modules crashing the main-thread bundle) — running every real app shape through the dual-thread pipeline is worth doing for correctness alone.

3. Real threads

Takeaway: with a genuine thread boundary, IFR wins on content-first screens — FCP about −8% to −26% depending on the example set and host, with campaign medians of −19% (ten demos) and −12% (later seven-app mix). The win comes from removing background boot + IPC from the critical path. On small screens the two IFR configurations (with and without ET) are usually within a few percent on web FCP: ET's clearest measured advantages remain render cost and ops payload (campaign 1) and native cold starts.

Setup: Lynx for Web — the background runtime runs in a genuine Web Worker with real postMessage IPC, in headless Chromium. FCP = <lynx-view> insertion → first painted content, medians of 7 fresh browser contexts. Both throttle levels below come from one session measured under the current flag semantics (enableIFR: true = IFR + ET; explicit opt-out = IFR without ET). Absolute milliseconds drift with host load between sessions; within-run ratios are the stable currency.

The ReactLynx control

ReactLynx has no IFR-off switch, so "off" is emulated faithfully (empty main-thread first screen, full background render + hydration). First, in the same single-process harness as campaign 2, ReactLynx is just as flat as Vue Lynx — the reference implementation cannot demonstrate its own headline feature without a thread boundary either:

ReactLynx probe (single process, jitless)FCPTTI
IFR on25.3 ms32.7 ms
IFR off (emulated)24.3 ms24.3 ms

On real threads both frameworks win, by the same class of margin: the ReactLynx probe (85 nodes) improves 97.7 → 75.2 ms (−23%), squarely inside the range the Vue examples below span.

Full matrix — no CPU throttle

example (nodes)No IFRIFR w/o ETΔIFR + ET (default)Δ
hello-world (16)96.675.8−22%75.4−22%
todomvc-day1 (7)92.978.6−15%73.4−21%
swiper (39)100.577.7−23%79.1−21%
tailwindcss (68)95.780.9−15%80.6−16%
keep-alive (49)103.586.0−17%84.4−18%
transition (55)104.693.3−11%85.1−19%
7guis (145)104.190.3−13%80.6−23%
gallery (302)136.6104.5−23%109.9−20%
css-features (50)102.093.9−8%100.3−2%
hackernews-css (18)115.3131.7+14%131.8+14%

(FCP in ms. Medians: −19% default, −15% without ET — the per-example differences between the two configurations flip between runs and should be read as noise. The Hacker News row here pre-dates shell IFR; see §3b.)

4× CPU throttle

exampleNo IFRIFR w/o ETΔIFR + ETΔ
hello-world344.1282.8−18%289.1−16%
todomvc-day1313.2281.7−10%280.6−10%
swiper333.1304.9−8%298.4−10%
tailwindcss339.9330.7−3%316.4−7%
keep-alive391.4366.6−6%370.8−5%
gallery (302 nodes)437.2430.5−2%434.5−1%
css-features352.0353.6+0%346.4−2%
transition350.6355.2+1%367.6+5%
7guis352.1366.8+4%355.0+1%
hackernews-css324.0352.9+9%360.0+11%
ReactLynx probe331.6308.0−7%

Two readings from this campaign:

  1. Content-first screens keep a win under throttling on this set (hello-world −16…−18%, todomvc-day1 −10%, swiper −10%), while screens whose FCP is dominated by CSS processing or big bundles compress toward zero — a throttled CPU multiplies the bundle-parse term that the web platform pays on the FCP path.
  2. The inversion profile reproduces at both throttle levels when the first paint has little sync chrome. The Hacker News row above paid +14% at full speed and +9…11% throttled while the example skipped the IFR mount entirely. After shell IFR (always mount; gate fetches), the same app flipped to about −12% at full speed — see §3b. On native, precompiled lepus bytecode shrinks the parse term inversions are made of.

How far is this from plain single-threaded web?

Vanilla single-threaded baselines (plain Vue via @vue/runtime-dom; plain Preact via ReactLynx's exact fork pointed back at the real DOM) decompose the remaining gap. On the ReactLynx probe screen, no throttle:

componentcostevidence
render 85 elements + paint (preact on DOM)~21 msplain-preact, framework pre-parsed
+ framework fetch+parse on the FCP path+7 mscold − warm
+ the Lynx-for-Web platform layer+47 msrl-ifr − plain-preact cold
+ background boot + hydration IPC round-trip+23 msrl-noifr − rl-ifr

The thread boundary costs ~23 ms and IFR removes exactly that slice. The remaining gap vs plain web is the web-host emulation layer — a per-host constant that native Lynx, whose platform layer is native code, does not pay.

3b. Large-app reevaluation

Takeaway: keep the campaign-3 story for small content-first screens, and widen the range once product-sized apps join. A second Lynx-for-Web pass (explicit off / et / ifr / ifr-et flags; hello-world, TodoMVC, gallery, Hacker News, AI Chat, Elk) landed content-first medians around −12% at full speed — inside the same −8%…−26% band as campaign 3's per-example spread, but a lower suite median than −19%. Under 4× throttle, Elk / AI Chat regress about +25% to +44%; the all-seven median flips slightly positive. Bundle gzip stays about ×2.2–2.5 (median ×2.23 here vs ×2.26 in campaign 2).

sourcesetIFR + ET FCP vs No IFR (no throttle)notes
Campaign 3 (Claude Code)10 mostly small examples−8% to −23%, median −19%ReactLynx control −23%
Campaign 3b (Cursor)7 apps incl. TodoMVC, gallery, HN, AI Chat, Elkcontent-first ~−12% (hello −26%); all-seven median −12% with HN after shell IFRabsolute ms lower on a different host; compare ratios
Campaign 3b @ 4× throttlesame 7content-first ~−6%; all-seven median ~+3%Elk +44%, AI Chat +25%

Focus matrix — no CPU throttle (campaign 3b)

Four explicit configs. FCP ms, medians of 7. ifr = IFR with enableElementTemplates: false.

examplenodes†offetifrifr+etifr+et Δ
hello-world1663.660.048.647.0−26%
todomvc-day1755.456.953.150.3−9%
todomvc1163.271.649.155.9−12%
gallery30474.775.861.362.3−17%
hackernews-css (shell IFR)34771.564.163.0−12%
ai-chat10490.490.374.974.9−17%
elk956100.399.696.8100.2−0%

† Settled node count after hydration / fetch. Hacker News remounted with shell IFR in this pass (earlier campaign-3 row skipped app.mount() on the main thread and inverted). AI Chat / Elk leave IFR off in-tree; numbers are forced-on for the matrix.

Same set — 4× CPU throttle

exampleoffifrifr+etifr+et Δ
hello-world163.6158.1141.1−14%
todomvc-day1157.3160.5147.2−6%
todomvc168.4157.9159.0−6%
gallery206.6206.0213.0+3%
hackernews-css172.5183.5186.7+8%
ai-chat200.9239.6251.1+25%
elk235.6338.6340.0+44%

Reading across §3 and §3b: treat −12% to −19% as the plausible content-first median band on Lynx for Web, not a single headline −19%. ET-only remains roughly free on size (~+1%) and flat on web FCP; keep defaulting ET with IFR for the strategy-ladder render-cost win (about 6–15× across reruns), not because it moves small-screen web FCP.

Raw JSON and write-up: packages/ifr-bench/reeval/.

4. Native engine observations

The full example suite was validated on a native simulator (LynxExplorer, Lynx SDK 1.4 / PrimJS): 24/25 examples pass with zero hydration mismatches, and No-IFR vs IFR+ET screens are visually identical (SSIM ≥ 0.9977; scoped CSS, baked inline styles, and auto-px semantics all correct on the native engine). Single-sample cold-start recordings show first content ~0.3 s earlier (gallery) up to ~1.2 s earlier (7guis) with IFR — directional confirmation of the mechanism, not a stable benchmark.

Reproduce

# strategy ladder + correctness oracle
pnpm --filter vue-lynx-ifr-bench run check
pnpm --filter vue-lynx-ifr-bench run bench

# all-examples sweep
node packages/ifr-bench/examples-sweep/orchestrate.mjs <dir>
node packages/ifr-bench/examples-sweep/sweep.mjs <dir>

# real-browser (Lynx for Web) measurement, with plain-web baselines
node packages/ifr-bench/web-harness/run-browser.mjs <bundlesDir> [runs] [throttle]

# focused 4-config rebuild (incl. ai-chat / elk) used for §3b
node packages/ifr-bench/reeval/orchestrate-focused.mjs <dir>