Cerious-Scroll performance benchmark
Everything on this page is measured in your browser when you press Run. Nothing is pre-recorded. The harness drives the same engine the demos use and reports per-frame cost, mount time, random-access latency, DOM footprint and heap.
Configure
Pick one row template and the dataset sizes to walk through. If the engine is doing its job, every number in the results stays flat as the row count climbs.
Pick one dataset size and the row templates to walk through, from a four-node row up to a sixty-node panel with gradients and drop shadows. This is what separates engine cost from renderer cost.
Hover a template to see what it stresses.
Run
The surface below is the engine under test. It stays visible and interactive the whole time, so you can watch each phase happen and grab it yourself once the run finishes.
Results
Nothing measured yet. Run the sweep above and the numbers land here.
Every dataset size
| Rows | Mount | Frame p50 | Frame p95 | FPS | Worst frame | Jank | Jump p95 | DOM nodes | Heap |
|---|---|---|---|---|---|---|---|---|---|
| No scale sweep has been run yet. | |||||||||
Every row template
| Row template | Nodes / row | Row height | Mount | Frame p50 | Frame p95 | FPS | Jank | Renders / frame | Per row | Live nodes |
|---|---|---|---|---|---|---|---|---|---|---|
| No complexity sweep has been run yet. | ||||||||||
Click any row to select it. The Frame timeline tab follows your selection.
Frame timeline
Every animation frame of the selected run, in order. Green is the wall-clock gap between frames, blue is the JS the library spent, and the dashed line is the display budget. On a healthy run the green trace sits on the budget rather than below it: the browser paces frames to the refresh rate, so matching it is the best result available. What matters is the shaded band above, where missed frames appear.
Cost against dataset size
The dataset grows by orders of magnitude on a log scale. Flat lines mean cost is independent of row count.
Cost against row complexity
Per-frame JS cost plotted against the number of DOM nodes each row template creates.
The same harness, run against a plain scrollable div with every row mounted. Frame work is not compared here: the unvirtualized list does almost no JavaScript per frame because the browser pays the cost at layout and paint instead. So the honest comparison is the wall-clock frame interval, plus what it costs to mount and how much DOM stays resident.
| Rows | Mountvirtualized → unvirtualized | Frame intervalvirtualized → unvirtualized | DOM nodesvirtualized → unvirtualized |
|---|---|---|---|
| No baseline measured yet. | |||
Resident DOM nodes, both engines
Log scale on both axes. One line stays flat; the other tracks the dataset until it cannot be built at all.
Export
Every export carries the full environment block, browser, cores, device memory, DPR and measured refresh rate, alongside the numbers from both sweeps. A result should never be quoted without the machine that produced it.
How it works what is measured, and what it is not
-
Refresh-rate calibration. Before anything runs, the harness samples idle
requestAnimationFrameintervals and takes the median to derive your display's refresh rate. That sets the frame budget behind the jank and FPS columns, so on a 120 Hz panel the budget works out at 8.3 ms rather than 16.7 ms. - Warm-up, discarded. Each case gets a short scroll burst whose frames are thrown away, so JIT warm-up and first-paint costs never land in the reported numbers.
-
Mount. Constructs a fresh
CeriousScroll, renders the first viewport and forces a layout flush. Repeated n times; the median is reported, which is far more stable than a mean under background noise. -
Sustained scroll. A rAF loop applies a fixed pixel delta through
scroll(delta, viewportHeight)and repaints viarenderViewport(), bouncing at either end of the dataset. Two things are recorded per frame: the frame interval, the wall-clock gap between frames, which carries browser layout, paint and compositing with it; and the JS work the library itself spent. FPS comes from the intervals, while frame p50/p95 report the library's own cost. -
Random access. Jumps to uniformly random indices across the whole dataset
via
jumpToElement(), repaints, and forces layout. This is the "seek to row 7,431,902" case, and it is where true virtualization parts company with DOM clustering. - DOM footprint. Counts every element resident inside the scroll surface at steady state. This is the O(1) claim in its most falsifiable form: the count must not grow with the dataset.
-
Row complexity. The complexity sweep pins the dataset size and walks the
row template instead. There is a plain card, a twelve-column grid row with a sparkline, a
media card with an aspect-ratio thumbnail, star rating and avatar stack, an inline
<svg>chart built from gridlines, bars, an area path and a polyline, a row of native form controls, a sixteen-level-deep tree, and a panel carrying gradients, drop shadows and a transform. Every one of them is built from local DOM only, with no network images and no remote fonts, so a run stays deterministic and works offline. The node count on each chip is measured rather than asserted: five rows go into a detached, off-screen probe and the elements are counted. -
The control group. With the baseline enabled, every dataset size is
also measured against a plain scrollable
<div>holding every row, which is what you get without a virtualiser. It is built the way a competent developer would build it, oneDocumentFragmentand one append, and it renders the identical row template, so it is a fair control rather than a strawman. Two safety limits apply, and both are reported as results rather than hidden: a size needing more than two million DOM nodes is refused outright, and a build that has not finished within ten seconds is abandoned with the row count it reached. One caveat matters for reading the numbers: per-frame JS work is not comparable between the two, because the unvirtualized list does almost nothing in JavaScript per frame while the browser pays the cost at layout and paint. The comparison table therefore comes down to wall-clock frame interval, mount time, and resident DOM. - Engine cost against renderer cost. The harness counts renderer invocations during the measured scroll. Renders / frame is how many rows genuinely entered the viewport on that frame, and it should stay in the low single digits no matter how large the dataset gets. Per row divides the total per-frame JS by those invocations to give the average cost of building one row. Read together, they tell you which side of the boundary your frame budget is going to: the virtualiser, or your own row template.
performance.memory,
which is Chromium-only, quantised, and at the mercy of garbage collection timing, so treat it
as an order-of-magnitude signal rather than a precise figure. Sub-millisecond results sit
close to the floor of the clock itself, since browsers quantise
performance.now() to roughly 100 µs; a 0.30 ms frame and a
0.35 ms frame are not meaningfully different, so raise the fling velocity or reach for a
heavier row template when you want separation. Numbers also move with background tabs,
battery saver, thermal state and extensions, so close the noisy ones before quoting
anything. And remember the harness measures the scroller together with whatever renderer you
point it at: a heavier row template lifts every column, which is exactly why the template is
a knob you can turn.