Performance Optimization Guide #
This document outlines performance optimizations for the vlist virtual scrolling component. Many optimizations are already implemented, with concrete remaining opportunities organized by category.
Implemented Optimizations ✅ #
The following optimizations are already implemented in vlist:
Core Optimizations (Always Active) #
- Element Pooling - DOM elements are recycled via
createElementPool() - Compression - Large lists (1M+ items) use virtual scroll space compression
- Event Delegation - Single click listener on items container
- Reusable Compression Context - Avoids object allocation per frame (
reusableCompressionCtxin context) - Cached Compression State - Only recalculates when
totalItemschanges (getCachedCompression) - Zero-Allocation Scroll Hot Path - Cached compression passed to
updateViewportStateand range functions; noCompressionStateorRangeobjects allocated per frame - In-Place Range Mutation -
calculateCompressedVisibleRangeandcalculateCompressedRenderRangeaccept optionaloutparameter to mutate existing range objects - RAF-Throttled Native Scroll -
handleNativeScrollwrapped withrafThrottleto guarantee at most one processing per animation frame - CSS Containment -
contain: layout styleon items container,contain: content+will-change: transformon items for optimized compositing - Scroll Transition Suppression -
.vlist--scrollingclass toggled during active scroll to disable CSS transitions, re-enabled on idle - Sparse Storage with LRU Eviction - Efficient memory management for large datasets
- Idle Detection - Defers non-critical operations until scroll stops
- DocumentFragment Batching - New elements are batched and appended in a single DOM operation
- Direct Property Assignment - Uses
datasetandariaSelectedinstead ofsetAttribute - Static Role Attribute -
role="option"set once in element pool, not per render - Reusable ItemState Object - Single object reused to reduce GC pressure
- ResizeObserver - Automatic viewport recalculation on container resize
- Circular Buffer Velocity Tracker - Pre-allocated buffer, zero allocations during scroll
- Configurable Chunk Preloading - Preloads items ahead based on scroll direction and velocity
- Cheap Pool Release -
textContent=""instead ofinnerHTML=""in element pool release (no HTML parser invocation) - Batched LRU Timestamps - Single
Date.now()call per render viatouchChunksForRange()instead of per-item instorage.get() - In-Place Focus Mutation -
moveFocusUp/Down/ToFirst/ToLast/ByPagemutatefocusedIndexdirectly, zero object allocations - Targeted Keyboard Focus Render - Arrow keys update only 2 affected items via
updateItemClasses()instead of full-rendering all ~20-50 visible items - Direct State Getters - Hot paths use
getTotal(),getCached()etc. instead of allocating state objects viagetState() - CSS-Only Static Positioning - Items use
.vlist-itemCSS forposition:absolute;top:0;left:0;right:0; only dynamicheightset via JS - Split Core/Extras CSS - Core styles (6.7 KB) separated from optional variants, loading/empty states, and animations (3.4 KB extras)
- Re-exported Range Functions -
calculateVisibleRangeandcalculateRenderRangeare direct re-exports from compression, eliminating pass-through wrappers - Configurable Idle Timeout -
idleTimeoutoption onBuilderConfig(default: 150ms) for tuning scroll idle detection per device
Configuration Options #
Loading Behavior #
Control velocity-based loading and preloading via the loading config:
const list = vlist({
container: '#list',
item: {
height: 50,
template: myTemplate,
},
adapter: myAdapter,
loading: {
// Velocity above which loading is skipped entirely (px/ms)
// Default: 25
cancelThreshold: 25,
// Velocity above which preloading kicks in (px/ms)
// Default: 2
preloadThreshold: 2,
// Number of items to preload ahead of scroll direction
// Default: 50
preloadAhead: 50,
},
});
Velocity-based loading strategy:
| Scroll Speed | Velocity | Behavior |
|---|---|---|
| Slow | < preloadThreshold |
Load visible range only |
| Medium | preloadThreshold to cancelThreshold |
Preload items ahead |
| Fast | > cancelThreshold |
Skip loading, defer to idle |
Tuning tips:
- Slow API? Increase
preloadAhead(e.g., 100-200) - Heavy templates? Decrease
preloadAhead(e.g., 20-30) - Disable preloading: Set
preloadThreshold: Infinity
Idle Timeout #
Control how long after the last scroll event before the list is considered "idle":
const list = vlist({
container: '#list',
item: { height: 50, template: myTemplate },
adapter: myAdapter,
idleTimeout: 200, // ms (default: 150)
});
When idle is detected, vlist:
- Loads any pending data ranges that were skipped during fast scrolling
- Re-enables CSS transitions (removes
.vlist--scrollingclass) - Resets the velocity tracker
Tuning tips:
- Mobile/touch devices: Increase to 200-300ms (scroll events have larger gaps)
- Desktop with smooth scroll: Default 150ms works well
- Aggressive loading: Decrease to 100ms (loads data sooner after scroll stops)
Resize Handling #
The resize event is emitted when the container dimensions change:
list.on('resize', ({ height, width }) => {
console.log(`Container resized to ${width}x${height}`);
});
Template Authoring Guidelines #
ItemState Object Reuse #
The state parameter passed to templates is reused to reduce GC pressure. Templates should:
// ✅ Good - read state immediately
const template = (item, index, state) => {
const className = state.selected ? 'item selected' : 'item';
return `<div class="${className}">${item.name}</div>`;
};
// ❌ Bad - storing state reference
const template = (item, index, state) => {
item._state = state; // Don't do this! State object is reused
return `<div>${item.name}</div>`;
};
Efficient Templates #
For best performance:
// ✅ Simple string templates (fastest)
const template = (item, index, state) =>
`<div class="item ${state.selected ? 'selected' : ''}">${item.name}</div>`;
// ✅ HTMLElement templates (good for complex layouts)
const template = (item, index, state) => {
const el = document.createElement('div');
el.className = state.selected ? 'item selected' : 'item';
el.textContent = item.name;
return el;
};
Remaining Optimization Opportunities #
Concrete improvements organized by category and priority.
🚀 Speed (Hot Path Allocations) #
~~S1. Remove `innerHTML = ""` from element pool release~~ ✅ Implemented #
Replaced with textContent = "" (no HTML parser invocation). Content is overwritten on next acquire() → applyTemplate().
~~S2. Batch `Date.now()` in sparse storage access~~ ✅ Implemented #
Added touchChunksForRange(start, end) that calls Date.now() once per render cycle. Removed per-item Date.now() from storage.get().
~~S3. Avoid `SelectionState` allocation on every arrow key~~ ✅ Implemented #
Focus movement functions (moveFocusUp/Down/ToFirst/ToLast/ByPage) now mutate state.focusedIndex in-place instead of spreading new objects.
~~S4. Lazy-build `getState()` in data manager~~ ✅ Implemented #
All hot paths (vlist.ts) now use direct getters (getTotal(), getCached()) instead of getState(). Removed [...pendingRanges] array copy — getState() passes direct reference since callers don't mutate it.
🎬 Smoothness (Rendering & Scroll Feel) #
~~M1. Targeted re-render on keyboard focus change~~ ✅ Implemented #
Arrow key navigation now uses renderer.updateItemClasses() on just the 2 affected items (old focus → remove class, new focus → add class) instead of full-rendering all ~20-50 visible items. Space/Enter (selection changes) still trigger full render.
~~M2. Make idle timeout configurable~~ ✅ Implemented #
Added idleTimeout option to both BuilderConfig and ScrollControllerConfig. Defaults to 150ms. Consumers can tune for mobile/slower devices.
📦 Size (Bundle & CSS Weight) #
Z1. Deduplicate dark mode CSS — ⏸️ Deferred #
Status: Deferred. The ~400 bytes of raw duplication between @media (prefers-color-scheme: dark) and .dark {} compresses to near-zero with gzip (identical repeated patterns). Pure CSS has no mechanism to share declarations between media query and non-media-query contexts without a preprocessor. The duplication supports both auto dark mode and class-based dark mode (Tailwind), which consumers expect.
~~Z2. Split unused CSS into a separate file~~ ✅ Implemented #
Core styles split from optional presets. dist/vlist.css (6.7 KB) contains tokens, base layout, item states, and custom scrollbar. dist/vlist-extras.css (3.4 KB) contains variants, loading/empty states, utilities, and animations. Available via import 'vlist/styles/extras'.
Z3. Lazy-initialize placeholder manager 🟡 Low Impact #
Problem: createPlaceholderManager() is always instantiated in the data manager, even for static lists with items: [...] that never need placeholders. The placeholder module includes structure analysis, field detection, and masked text generation (~300 lines).
File: src/features/async/manager.ts — createDataManager
Fix: Create the placeholder manager lazily, only when the first unloaded item is requested:
// Before
const placeholders = createPlaceholderManager<T>(placeholderConfig);
// After
let placeholders: PlaceholderManager<T> | null = null;
const getPlaceholders = () => {
if (!placeholders) {
placeholders = createPlaceholderManager<T>(placeholderConfig);
}
return placeholders;
};
This keeps the code tree-shakeable for bundlers and avoids initialization cost for static lists.
~~Z4. Use CSS class instead of inline `style.cssText` for static styles~~ ✅ Implemented #
applyStaticStyles now only sets element.style.height — position:absolute;top:0;left:0;right:0 are already defined in the .vlist-item CSS class. Removes per-element cssText string parsing.
~~Z5. Eliminate thin pass-through wrappers in virtual.ts~~ ✅ Implemented #
calculateVisibleRange and calculateRenderRange replaced with direct re-exports from compression.ts (calculateCompressedVisibleRange as calculateVisibleRange). Removed ~40 lines of wrapper code + JSDoc duplication.
🟢 Situational Optimizations (Consumer-Side) #
These optimizations are not implemented in vlist and only beneficial in specific scenarios:
Template Result Caching #
For templates with very expensive computations (>1ms per item):
const templateCache = new WeakMap<T, HTMLElement>();
const cachedTemplate = (item, index, state) => {
let cached = templateCache.get(item);
if (!cached) {
cached = expensiveTemplate(item, index, state);
templateCache.set(item, cached);
}
// Clone and update state-dependent parts
const clone = cached.cloneNode(true) as HTMLElement;
clone.classList.toggle('selected', state.selected);
return clone;
};
When to use: Only if your template involves heavy computation (parsing, complex calculations). Most templates don't need this.
Web Worker for Data Processing #
For adapters that transform large amounts of data:
// worker.ts
self.onmessage = (e) => {
const { items } = e.data;
const transformed = items.map(item => ({
...item,
computedField: expensiveComputation(item),
}));
self.postMessage(transformed);
};
// adapter
const worker = new Worker('./transform-worker.ts');
const adapter = {
read: async (params) => {
const raw = await fetchItems(params);
return new Promise(resolve => {
worker.postMessage({ items: raw.items });
worker.onmessage = (e) => {
resolve({ items: e.data, total: raw.total });
};
});
},
};
When to use: Only if data transformation causes visible frame drops during scrolling.
Benchmarking #
Measuring Performance #
// Frame timing
let lastFrame = performance.now();
const measureFrame = () => {
const now = performance.now();
const delta = now - lastFrame;
if (delta > 16.67) {
console.warn(`Frame drop: ${delta.toFixed(2)}ms`);
}
lastFrame = now;
requestAnimationFrame(measureFrame);
};
requestAnimationFrame(measureFrame);
// Operation timing
const start = performance.now();
list.scrollToIndex(500000, 'center');
console.log(`Scroll took ${performance.now() - start}ms`);
Chrome DevTools Profiling #
- Open Performance tab
- Start recording
- Scroll the list rapidly for 5-10 seconds
- Stop recording
- Look for:
- Long tasks (>50ms) - indicates blocking operations
- Excessive GC pauses - indicates too many allocations
- Layout thrashing - indicates DOM inefficiency
Expected Performance #
With all optimizations enabled:
- Scroll FPS: 60fps sustained
- Initial render: <50ms for 50 items
- Memory: Stable (no growth during scrolling)
- GC pauses: Minimal (<5ms)
Summary #
Implemented #
| Optimization | Impact |
|---|---|
| Element pooling | High |
| DocumentFragment batching | High |
| Compression for large lists | High |
| Sparse storage + LRU | High |
| Zero-allocation scroll hot path | High |
| RAF-throttled native scroll | High |
| Reusable Compression Context | Medium |
| Cached Compression State | Medium |
CSS containment + will-change |
Medium |
| Scroll transition suppression | Medium |
| Direct property assignment | Medium |
| Reusable ItemState | Medium |
| ResizeObserver | Medium |
| Circular buffer velocity | Medium |
| Configurable preloading | Medium |
| Idle detection | Medium |
| Event delegation | Medium |
| Static role attribute | Low |
Cheap pool release (textContent) |
Low |
| Batched LRU timestamps | Low |
| In-place focus mutation | Low |
| Targeted keyboard focus render | Medium |
| Direct state getters | Low |
| CSS-only static positioning | Low |
| Split core/extras CSS | Medium |
| Re-exported range functions | Low |
| Configurable idle timeout | Low |
Pending — Priority Matrix #
| # | Optimization | Impact | Effort | Category |
|---|---|---|---|---|
| Z1 | Deduplicate dark mode CSS | 🟡 Low | N/A | Size (deferred — gzip handles it) |
| Z3 | Lazy-init placeholder manager | 🟡 Low | Medium | Size |
Completed: S1 ✅, S2 ✅, S3 ✅, S4 ✅, M1 ✅, M2 ✅, Z2 ✅, Z4 ✅, Z5 ✅ Deferred: Z1 (gzip makes duplication negligible) Remaining: Z3 (lazy placeholders — medium effort, low impact)
Related Documentation #
- Scale - How large list scaling works
- Async Management - Sparse storage and chunking
- Scroll Controller - Velocity tracking and scroll handling
- Rendering - Element pooling and DOM management
- Types - Configuration interfaces including
LoadingConfig