vlist v2: Zero-Allocation Architecture #
May 2026
vlist v2 is a ground-up rewrite of the core engine. The API is simpler, the bundle is smaller, and the scroll path allocates nothing.
What changed #
New API #
The builder chain is gone. createVList takes a config and an optional plugins array, returns the instance immediately.
v1:
import { vlist, withGrid, withSelection } from "vlist"
const list = vlist({
container: "#list",
item: { height: 56, template: (item) => `...` }
}).use(withGrid({ columns: 3 }))
.use(withSelection())
.build()
list.setItems(data)
v2:
import { createVList, grid, selection } from "vlist"
const list = createVList({
container: "#list",
items: data,
item: { height: 56, template: (item) => `...` }
}, [grid({ columns: 3 }), selection()])
No .build(), no .use() chains. Items can be passed in the config directly. Plugins are plain functions — withGrid became grid, withSelection became selection, and so on for all 14 plugins.
2-Phase Pipeline #
v1 used a monolithic render loop. v2 splits it into two strict synchronous phases:
Phase 1 — Calculate & Reconcile. Given a scroll position, determine which items are visible. Write indices, offsets, and sizes into pre-allocated TypedArrays on a persistent EngineState singleton. No objects created, no arrays allocated.
Phase 2 — Commit. Read directly from the EngineState buffers. Iterate visibleCount entries, apply transforms to DOM nodes. Strict sub-operation ordering: acquire nodes → bind identity → position → release stale nodes.
Measurement (ResizeObserver) is not a pipeline phase. It's an external async observer that updates the size cache and schedules a new Phase 1 via requestAnimationFrame.
Zero-Allocation Hot Path #
The EngineState is instantiated once at creation. It holds pre-allocated Int32Array and Float64Array buffers for visible indices, offsets, and sizes. The pipeline mutates these in place — no intermediate { node, offset, data } objects, no per-frame array allocations.
Hooks are compiled into frozen linear arrays during creation. The hot path iterates them with a simple for loop — no dynamic dispatch, no closure creation.
for (let i = 0; i < calculateHooks.length; i++) {
calculateHooks[i](engineState)
}
Plugin Architecture #
The VListFeature interface was replaced by VListPlugin. Plugins declare their hooks, priority, and conflicts upfront:
interface VListPlugin<T> {
name: string
priority?: number
conflicts?: string[]
setup(ctx: PluginContext<T>): void
hooks?: {
onCalculate?: (state: EngineState) => void
onCommit?: (state: EngineState) => void
onAfterScroll?: () => void
onIdle?: () => void
onResize?: () => void
}
destroy?(): void
}
Plugins register handlers and public methods through PluginContext instead of replacing core functions. The shared methods Map is gone — plugins use registerMethod() to extend the instance cleanly.
DOM Structure #
The .vlist-items wrapper was removed. The DOM is now three elements: root > viewport > content. Less nesting, simpler styling.
Bundle Size #
v2 is significantly smaller across the board.
| Module | v1 (gzip) | v2 (gzip) | Change |
|---|---|---|---|
| Base | 11.2 KB | 5.0 KB | −55% |
| grid | 4.1 KB | 1.7 KB | −59% |
| selection | 2.7 KB | 1.2 KB | −56% |
| groups | 4.7 KB | 2.5 KB | −47% |
| autosize | 0.9 KB | 0.6 KB | −33% |
| masonry | 3.4 KB | 2.9 KB | −15% |
| async | 4.6 KB | 3.9 KB | −15% |
| transition | 2.1 KB | 1.9 KB | −10% |
| scale | 3.6 KB | 3.4 KB | −6% |
| sortable | 2.9 KB | 3.0 KB | +3% |
The base went from 11.2 KB to 5.0 KB gzipped — less than half. Most plugins shrank proportionally. sortable grew slightly due to new features (drag handles, multi-list transfer).
Performance #
v2 hits 120 FPS with a 0.6ms frame budget on default lists. The zero-allocation pipeline means no GC pressure during scrolling — the memory profile stays flat whether you're rendering 1,000 items or 1,000,000.
All v1 safety guarantees are preserved: render count cap, zero container size early exit, overscan, compressed mode virtual offset mapping, and synchronous wheel rendering.
Migration #
The breaking changes are primarily naming and structure:
vlist()→createVList().use(withX()).build()→ second argument[x()]- All
withXplugin factories →x(drop thewithprefix) VListFeature→VListPluginBuilderContext→PluginContext.vlist-itemsCSS selector → target.vlist-contentinstead
For most applications, migration is a find-and-replace. The instance API (setItems, scrollToIndex, on, destroy, etc.) is unchanged.
What's Next #
The core architecture hit its performance ceiling — there's nothing meaningful left to optimize on the hot path. Future work is data-driven, gated by real-world profiling rather than speculation. The RFC-004 optimization tiers beyond Tier 1 were intentionally shelved because they'd add complexity chasing gains that don't exist at 0.6ms frame times.
The focus shifts to ecosystem: framework adapters, new plugin ideas, documentation, and developer experience.
Install: npm install vlist
Docs: vlist.io/docs
Source: github.com/floor/vlist