Performance

How the two engines perform, measured with scripts/perf-image-grid.ts (see the project doc Performance testing). Numbers are medians of three runs against a production build, headless Chrome at 1440 × 900, DPR 2, CPU slowed 4×, empty cache. Raw results: perf/results/.

Results, 2026-10-08

EngineHitsLCP msTBT msINP msDOM nodesImages on load (KB)Frames > 33 ms in scrollLong frames / blocking ms in scrollTiles in DOM after scroll
TanStack100772364030040 (1 165)00 / 0100 (all)
TanStack2 000580353225730 (878)10 / 053
React Aria1006683613281 93539 (1 137)10 / 0100 (all)
React Aria2 0005448810463426 (759)2113 / 4032

CLS was 0 in every run. Measured after fixing a crash that made pages over 200 hits skip server rendering. The rows themselves still appear only after hydration, once the width is measured. INP is the slowest interaction: switching the layout to the grid and back (both engines), and arrow keys (React Aria). Scrolling is 40 wheel steps of 400 px.

What it shows

  • TanStack is smooth. No long animation frames while scrolling, every interaction under 50 ms, about 250–300 DOM nodes at any result size.
  • React Aria costs more per tile. Each tile is a gridlist row with a checkbox and two buttons, wrapped by the Virtualizer: about 19 DOM nodes per tile against 3. Without the Virtualizer (100 hits) switching layout takes about 330 ms, which is "needs improvement" for INP (over 200 ms). Virtualized (2 000 hits) it stays responsive but drops frames while scrolling.
  • Not like for like. The React Aria tiles carry im-web's interactive overlay; the TanStack tiles are a link with the image and a focus ring. Adding the same overlay to TanStack tiles would raise its numbers too. The comparison to make before choosing is TanStack with an equivalent overlay and hand-built keyboard support.
  • Images are no longer the bottleneck. 256 px previews average about 29 KB; the first screen is about 1 MB instead of about 4 MB with 512 px previews. LCP varies with the network (the preview server is on the internet), so compare it over several runs.

React Aria: what it gives, what it lacks

React Aria GridList + VirtualizerTanStack Virtual
Screen-reader semanticsGrid with row count and indexNone (to build)
KeyboardLeft/right, Home/End, PageUp/PageDown, typeahead. Up/down doesn't work in justified rows: it only moves to a tile at exactly the same x, and a layout can only override left and right. We added a spatial up/down handler (keyNear).None (to build)
SelectionMultiple, shift-range, checkbox slot, select-all, onAction separate from selectNone (to build)
Focus while scrolled awayThe focused tile stays renderedLost when its row leaves the DOM
Load moreSentinel item; asked twice for the same page in testing, so the page dedupesOur two triggers, deduped
Window scrollYes (undocumented, in the source)Yes
LayoutCustom Layout subclass, semi-documented API: pin the versionOur own rows
Small results without virtualizationGridList in a flex-wrap, sized by the same packingRows in normal flow with content-visibility
Costreact-aria-components 1.21.1; more DOM and script per tile (above)@tanstack/react-virtual, small

Open

  • Decided 2026-10-08: TanStack (decision IG13). The React Aria version stays as the comparison, without further tuning.
  • DevTools trace, 2026-10-08 (React Aria, 2 000 hits, scrolling 18 000 px, CPU 4×): Chrome flags no forced reflows, so React Aria's long frames are rendering work, not its scroll handler measuring layout. Its image insight: the preview server sends JPEG only; WebP or AVIF would save 10–25 KB per image. That is for the image service, not the grid.
  • When the TanStack tiles get im-web's overlay (checkbox, Download, Add) and keyboard support, rerun the script to see what they cost.
  • Server-render the first rows, so LCP doesn't wait for hydration and the width measurement.