/ Blog

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:

  1. vlist()createVList()
  2. .use(withX()).build() → second argument [x()]
  3. All withX plugin factories → x (drop the with prefix)
  4. VListFeatureVListPlugin
  5. BuilderContextPluginContext
  6. .vlist-items CSS selector → target .vlist-content instead

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