# Widgets v1 vs v2 — Performance Comparison

> **Status:** Measured 2026-05-12 on Apple M4 Pro / macOS 26.4.1 / Chrome 147
> headless. Storybook dev mode (Vite HMR). 5 cold-mount runs per variant, 15
> total update ticks per dynamic variant (3 ticks × 5 runs). Medians reported.

## TL;DR

**v2 is ~2× faster than v1 across every variant measured.** Largest wins are
on dynamic mount (-60 % to -64 %) and 200-widget static (-60 %); the smallest
(but still substantial) win is the 200-widget update tick (-46 %).

| Variant                     | v1 (ms, median) | v2 (ms, median) |   Δ ms |    Δ % |
| --------------------------- | --------------: | --------------: | -----: | -----: |
| 100 widgets — static        |         2 354.3 |         1 110.8 | -1 243 | -52.8% |
| 200 widgets — static        |         7 831.2 |         3 159.4 | -4 672 | -59.7% |
| 100 widgets — dynamic mount |         2 613.9 |           944.5 | -1 669 | -63.9% |
| 200 widgets — dynamic mount |         7 500.5 |         2 988.1 | -4 512 | -60.2% |
| 100 widgets — update tick   |         1 073.9 |           419.2 |   -655 | -61.0% |
| 200 widgets — update tick   |         3 403.6 |         1 831.3 | -1 572 | -46.2% |

## Methodology

Both versions ship a matching performance story under Storybook. Each renders
a deterministic grid of widgets (seeded PRNG, seed `42`) across 9 widget types:
`bar, pie, timeseries, histogram, scatterplot, category, formula, spread, range`.

Each story logs to the console after a double-`rAF` (so paint is included),
once on mount and once per data refresh in the dynamic variants:

```
[Performance]    100 widgets rendered in 2354.3ms   ← v1 mount
[Performance v2] 100 widgets updated in 419.2ms     ← v2 per-tick update
```

### Stories

|                | v1                                                                                                     | v2                                                                                                        |
| -------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| Source         | [performance.stories.tsx](../../../../apps/storybook/stories/react-ui/widgets/performance.stories.tsx) | [performance.stories.tsx](../../../../apps/storybook/stories/react-ui/widgets-v2/performance.stories.tsx) |
| Storybook path | `React Ui/Widgets/Performance`                                                                         | `React UI/Widgets v2/Performance`                                                                         |
| Console prefix | `[Performance]`                                                                                        | `[Performance v2]`                                                                                        |

### Procedure

1. `pnpm storybook` (already running on `:6006`).
2. Headless Chrome started via CDP on port 9333 (separate user-data-dir, no
   throttling). Driver: [/tmp/perf-bench.mjs](/tmp/perf-bench.mjs) (one-off,
   not checked in).
3. For each variant: 5 fresh navigations. On each navigation we capture the
   first `[Performance...] rendered in Xms` log; for dynamic variants we also
   capture the next 3 `updated in Xms` logs (one per 3 s tick).
4. Medians (and min/max for sanity) computed across runs / ticks.

### Environment

- **Machine:** Apple M4 Pro, 24 GB RAM
- **OS:** macOS 26.4.1 (build 25E253)
- **Browser:** Chrome 147.0.7727.138, headless=new, no throttling
- **Storybook:** v10.3.6, dev mode (Vite HMR), production build numbers will differ
- **Date measured:** 2026-05-12

## Results

### Static render (cold mount)

Time from initial render commit to second-rAF paint.

| Variant     | v1 min/median/max (ms)           | v2 min/median/max (ms)          | Δ median (%) |
| ----------- | -------------------------------- | ------------------------------- | -----------: |
| 100 widgets | 2 214.5 / **2 354.3** / 2 509.6  | 1 020.0 / **1 110.8** / 1 132.5 |       -52.8% |
| 200 widgets | 7 150.4 / **7 831.2** / 12 067.5 | 2 888.9 / **3 159.4** / 3 517.6 |       -59.7% |

> v1's 200-widget run shows a notable outlier (12.1 s) on one of five runs; v2
> stays within a tight 2.9–3.5 s band. Median is robust to the outlier and is
> what should be cited.

### Cold mount inside the dynamic variant

Same as static, but rendered inside the `*Dynamic` story (which adds a sticky
update banner and a `setInterval`). Provided as a sanity check — these should
match the static numbers above and they do.

| Variant     | v1 min/median/max (ms)          | v2 min/median/max (ms)          | Δ median (%) |
| ----------- | ------------------------------- | ------------------------------- | -----------: |
| 100 widgets | 2 303.2 / **2 613.9** / 2 833.3 | 936.0 / **944.5** / 1 273.0     |       -63.9% |
| 200 widgets | 7 236.0 / **7 500.5** / 8 189.9 | 2 938.3 / **2 988.1** / 3 357.1 |       -60.2% |

### Dynamic update (per 3 s tick)

Time per data refresh — measured after each `setWidgetDefs(...)` commit,
through double-`rAF` to paint. 15 ticks per variant (3 ticks × 5 runs).

| Variant     | v1 min/median/max (ms)          | v2 min/median/max (ms)          | Δ median (%) |
| ----------- | ------------------------------- | ------------------------------- | -----------: |
| 100 widgets | 672.9 / **1 073.9** / 1 732.2   | 397.9 / **419.2** / 961.3       |       -61.0% |
| 200 widgets | 2 807.2 / **3 403.6** / 6 048.8 | 1 727.9 / **1 831.3** / 3 471.0 |       -46.2% |

> The first tick of each dynamic run is consistently the slowest in **both**
> versions (warmup / JIT / first ECharts dataset swap). v2 amortises this less
> dramatically than v1 — its update path is steadier (398–480 ms tail for 100
> widgets; 1 728–1 924 ms tail for 200) while v1 ticks remain noisy throughout
> (672–1 732 ms at 100; 2 807–6 049 ms at 200).

## Raw data

Captured to [/tmp/perf-bench.err](/tmp/perf-bench.err) during the run. Pasted
inline for reproducibility (the file is volatile / not checked in):

```
v1 / 100 static                    : 2509.6, 2354.3, 2249.9, 2214.5, 2408.7
v1 / 100 dynamic mount             : 2402.2, 2303.2, 2613.9, 2833.3, 2801.8
v1 / 100 dynamic ticks (3 × 5 run) : 1732.2, 973.4, 672.9 | 1599.1, 994.6, 782.0 | 1688.3, 1020.0, 811.8 | 1181.1, 1255.0, 1073.9 | 975.8, 1457.5, 1110.5
v1 / 200 static                    : 8711.4, 12067.5, 7831.2, 7420.5, 7150.4
v1 / 200 dynamic mount             : 8189.9, 7341.1, 7236.0, 7500.5, 7586.4
v1 / 200 dynamic ticks             : 5367.8, 2979.2, 3403.6 | 2807.2, 6048.8, 3734.0 | 2877.3, 6021.2, 3754.5 | 2965.0, 3712.3, 3152.1 | 2945.4, 3823.6, 3112.9
v2 / 100 static                    : 1111.6, 1132.5, 1038.1, 1020.0, 1110.8
v2 / 100 dynamic mount             : 1047.7, 944.5, 936.0, 938.9, 1273.0
v2 / 100 dynamic ticks             : 453.2, 407.4, 419.2 | 480.1, 397.9, 403.4 | 428.3, 398.2, 411.8 | 442.7, 412.6, 409.5 | 961.3, 454.0, 604.1
v2 / 200 static                    : 3517.6, 3413.2, 2888.9, 3159.4, 2940.4
v2 / 200 dynamic mount             : 2963.9, 3300.6, 3357.1, 2938.3, 2988.1
v2 / 200 dynamic ticks             : 1784.5, 1781.5, 2103.6 | 3208.2, 1924.8, 1834.8 | 3097.6, 1831.3, 1727.9 | 3471.0, 1808.5, 1766.4 | 3137.2, 1821.7, 1759.3
```

## Architectural notes that affect comparability

These are **already known differences** between v1 and v2 — they explain part
of the delta and matter when judging whether the comparison is apples-to-apples:

- **v2 uses pre-composed wrappers from `apps/storybook/stories/modules/widgets/`**
  (e.g. [`Bar`](../../../../apps/storybook/stories/modules/widgets/bar/bar.tsx)).
  These include `Widget.Provider`, `Widget.Toolbox`, `Widget.FullScreen.Slot`,
  `Widget.SelectionSummary`, etc. — i.e. v2 numbers include a slightly richer
  default toolbar surface that v1's perf story does not exercise. Despite
  doing **more** work, v2 still wins ~2× — the underlying primitives are
  faster, not the toolbar that's missing.
- **Datum shape differs.** v1 uses `{ category, value }`; v2 uses
  `{ name, value }`. Payload sizes are equivalent.
- **State management.** v1's `useWidgetStore` exposes per-widget config via
  `state.widgets[id]` (one global store, O(n) keys). v2 introduces per-widget
  stores via `getWidgetStore(id)` plus a `Widget.Provider` context. v2 trades
  more mount-time allocations for far fewer cross-widget broadcasts on update
  — which shows up clearly in the update-tick numbers (61 % faster at 100
  widgets, 46 % at 200).
- **ECharts pipeline.** v2 puts BOTH option phases on a single
  `optionFactory` closure passed as a prop to `Widget.Echart`: Echart's
  layout effect seeds `rawOptions` from the factory's structural branch
  once on mount, configTransforms (Stack/Zoom/Brush) mutate it via the
  middleware, then Echart's render-time `useMemo` calls the factory again
  to merge live `state.data` into the post-transform option. v1 builds
  the full option per render. v2 should win on dynamic updates (no
  re-init) — and does.
- **Ids.** The 200-widget variant in v2 uses `-dup`-suffixed ids so each of
  the 200 instances has its own per-widget store; v1 reuses the same id across
  the duplicate set (only 100 unique stores). Despite v2 having 2× more store
  instances, it still beats v1.

## Observations

- **Cold-mount cost direction:** v2 wins by ~53 % at 100 widgets and ~60 % at 200. Scaling is closer to linear in v2 (1.1 s → 3.2 s ≈ 2.9×) than in v1
  (2.4 s → 7.8 s ≈ 3.3×). At larger N the gap widens.
- **Dynamic-update cost direction:** v2 wins decisively (-61 % / -46 %). The
  per-tick distribution is much tighter — v2's option-factory-fed `setOption`
  avoids re-initialising charts.
- **Outlier widget types:** not measured per-type in this run. Hot candidates
  for follow-up: `timeseries` (largest data payload, ECharts brush+searcher
  toolbar) and `category` (DOM-heavy, no ECharts).
- **Variance:** v1 update ticks span 670–1 730 ms at 100 widgets — a 2.6×
  spread, with the first tick of each navigation always slowest. v2 ticks
  span 398–961 ms — 2.4× spread, but the 961 ms is a single outlier; the
  P75 is closer to 450 ms.
- **First-tick warmup.** Both versions show a slower first tick after mount
  (visible in both 100- and 200-widget update series). v2's first-tick
  penalty is smaller in absolute terms but proportionally similar.

## Next steps

- [ ] Per-widget-type breakdown — re-run with `WIDGET_TYPES = ['timeseries']`,
      then `['category']`, etc., to isolate which types contribute most to the
      v2 win and where any v2 regressions might hide.
- [ ] Production-build numbers — Storybook dev mode includes HMR, source-map
      eval, and React dev-mode checks. Expect both versions to be ~30 % faster
      on a `storybook build` artifact; the relative gap may narrow if v1's
      hotspots are dev-mode-only.
- [ ] Memory profile across 10 dynamic ticks — confirm no widget-store leak
      in v2 (per-widget stores must be torn down when ids change in the doubled
      variant).
- [ ] Lower-spec hardware — re-run on a baseline laptop to confirm the v2 win
      isn't M4-specific.
