| name | virtual-list |
| description | Implement efficient virtual scrolling for rendering large lists with DOM recycling, chunk-based rendering, and performance optimizations |
Virtual List Implementation Skill
Overview
This skill teaches you how to implement a high-performance virtual scrolling list component that can efficiently render thousands or millions of items by only creating DOM nodes for visible items plus a small buffer.
Analysis of Reference Implementation
The VirtualList component from /worldmonitor/src/components/VirtualList.ts demonstrates professional-grade virtual scrolling with the following patterns:
1. Chunk-Based Rendering Pattern
The implementation uses two complementary strategies:
VirtualList (Fixed-Height Items)
- Item Pool Management: Maintains a pool of reusable DOM elements (
PooledElement[])
- Viewport Calculation: Calculates visible range based on
scrollTop, itemHeight, and viewport dimensions
- Overscan Buffer: Renders extra items above/below viewport (
overscan parameter) to prevent flickering during scroll
WindowedList (Variable-Height Items)
- Chunk-Based Approach: Divides items into chunks (default 10 items per chunk)
- Lazy Chunk Rendering: Only renders chunks that are visible or within buffer range
- Placeholder Elements: Creates placeholders for all chunks upfront, renders content on-demand
2. Scroll Listener Optimization
private handleScroll = (): void => {
if (this.scrollRAF !== null) return;
this.scrollRAF = requestAnimationFrame(() => {
this.scrollRAF = null;
if (!this.isDestroyed) {
this.updateVisibleRange();
}
});
};
Key Optimizations:
- RequestAnimationFrame Throttling: Prevents multiple renders in same frame
- Passive Listeners:
{ passive: true } improves scroll performance
- Debounce Check: Guards against duplicate RAF calls
- Cleanup Check: Prevents rendering after destruction
3. DOM Recycling Strategy
The implementation uses a sophisticated two-pass recycling algorithm:
Pass 1: Identify Reusable Elements
for (const pooled of this.itemPool) {
if (pooled.currentIndex >= visibleStart && pooled.currentIndex < visibleEnd) {
usedIndices.add(pooled.currentIndex);
}
}
Pass 2: Recycle and Reassign
while (poolIndex < this.itemPool.length) {
const pooled = this.itemPool[poolIndex]!;
if (pooled.currentIndex < visibleStart || pooled.currentIndex >= visibleEnd) {
if (this.onRecycle) {
this.onRecycle(pooled.element);
}
pooled.currentIndex = i;
this.renderItem(i, pooled.element);
pooled.element.style.transform = `translateY(${i * this.itemHeight}px)`;
poolIndex++;
break;
}
poolIndex++;
}
Benefits:
- Minimizes DOM manipulation
- Reuses existing elements when possible
- Allows cleanup of event listeners via
onRecycle callback
- Uses
transform: translateY() for GPU-accelerated positioning
4. Performance Optimizations
Spacer Elements for Virtual Height
this.topSpacer.style.height = `${visibleStart * this.itemHeight}px`;
this.bottomSpacer.style.height = `${Math.max(0, (this.totalItems - visibleEnd) * this.itemHeight)}px`;
- Creates illusion of full list height without rendering all items
- Maintains accurate scrollbar size and position
Skip Unnecessary Updates
if (visibleStart === this.visibleStart && visibleEnd === this.visibleEnd) {
return;
}
CSS Positioning Strategy
element.style.position = 'absolute';
element.style.top = '0';
element.style.left = '0';
element.style.right = '0';
element.style.transform = 'translateY(-9999px)';
- Absolute positioning for precise control
- Transform for GPU acceleration
- Off-screen positioning instead of display:none (preserves element state)
ResizeObserver Integration
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => {
if (!this.isDestroyed) {
this.updateVisibleRange();
}
});
this.resizeObserver.observe(this.viewport);
}
- Automatically recalculates visible range when viewport resizes
- Progressive enhancement (checks for browser support)
Complete TypeScript Implementation Template
Below is a simplified but fully functional virtual list implementation that you can drop into any vanilla TypeScript project:
export interface SimpleVirtualListOptions {
container: HTMLElement;
itemHeight: number;
totalItems: number;
renderItem: (index: number, element: HTMLElement) => void;
overscan?: number;
onRecycle?: (element: HTMLElement) => void;
}
interface VirtualItem {
element: HTMLElement;
index: number;
}
export class SimpleVirtualList {
private container: ;
: ;
: ;
: ;
: ;
?: ;
: ;
: ;
: ;
: ;
: [] = [];
visibleStart = ;
visibleEnd = ;
: | = ;
destroyed = ;
() {
. = options.;
. = options.;
. = options.;
. = options. ?? ;
. = options.;
. = options.;
.();
.();
.();
}
(): {
.. = ;
. = .();
... = ;
. = .();
totalHeight = . * .;
... = ;
. = .();
... = ;
. = .();
... = ;
..(.);
..(.);
..(.);
..(.);
}
(): {
..(, ., { : });
}
handleScroll = (): {
(. !== ) ;
. = ( {
. = ;
(!.) {
.();
}
});
};
(): {
scrollTop = ..;
viewportHeight = ..;
startIndex = .(scrollTop / .);
endIndex = .((scrollTop + viewportHeight) / .);
bufferedStart = .(, startIndex - .);
bufferedEnd = .(., endIndex + .);
(bufferedStart === . && bufferedEnd === .) {
;
}
. = bufferedStart;
. = bufferedEnd;
... = ;
bottomHeight = .(, (. - bufferedEnd) * .);
... = ;
.();
}
(): {
visibleCount = . - .;
.(visibleCount);
activeIndices = <>();
( item .) {
(item. >= . && item. < .) {
activeIndices.(item.);
}
}
poolIndex = ;
( i = .; i < .; i++) {
(activeIndices.(i)) ;
(poolIndex < ..) {
item = .[poolIndex];
(item. < . || item. >= .) {
(.) {
.(item.);
}
item. = i;
.(i, item.);
.(item);
poolIndex++;
;
}
poolIndex++;
}
}
( item .) {
(item. >= . && item. < .) {
.(item);
item... = ;
} {
item... = ;
item... = ;
}
}
}
(: ): {
yOffset = item. * .;
item... = ;
}
(: ): {
(.. < requiredSize) {
element = .();
: = {
element,
: -,
};
..(item);
..(element, .);
}
}
(): {
element = .();
element.. = ;
element..();
element;
}
(: ): {
. = count;
totalHeight = . * .;
... = ;
.();
}
(: , smooth = ): {
offset = .(, .(index, . - )) * .;
..({
: offset,
: smooth ? : ,
});
}
(): {
( item .) {
item. = -;
}
.();
}
(): { : ; : ; : } {
{
: ..,
: .,
: .,
};
}
(): {
. = ;
(. !== ) {
(.);
. = ;
}
..(, .);
. = [];
.. = ;
}
}
(): {
container = .();
container. = ;
container.. = ;
..(container);
virtualList = ({
container,
: ,
: ,
: {
element. = ;
},
});
( {
virtualList.(, );
}, );
}
(): {
container = .() ;
virtualList = ({
container,
: ,
: ,
: {
element. = ;
btn = element.() ;
(btn) {
btn.(, {
.();
});
}
},
: {
btn = element.() ;
(btn) {
newBtn = btn.();
btn.?.(newBtn, btn);
}
},
});
( {
virtualList.();
}, );
}
<T> {
: ;
: T[] = [];
: ;
() {
. = renderer;
. = ({
container,
itemHeight,
: ,
: {
(index < ..) {
.(.[index], index, element);
}
},
});
}
(: T[]): {
. = data;
..(data.);
}
(): {
..();
}
(: ): {
..(index, );
}
(): {
..();
}
}
{
: ;
: ;
: ;
}
(): {
container = .() ;
userList = <>(
container,
,
{
element. = ;
}
);
: [] = .({ : }, ({
: i,
: ,
: ,
}));
userList.(users);
}
Key Implementation Patterns
1. DOM Structure
Container
└── Viewport (scrollable)
└── ContentWrapper (full height)
├── TopSpacer (variable height)
├── Item Elements (absolute positioned)
└── BottomSpacer (variable height)
2. Scroll Event Flow
Scroll Event → RAF Throttle → Calculate Visible Range → Update Spacers → Recycle Items → Render Content
3. Element Recycling Algorithm
- Calculate new visible range
- Identify elements still in range (reuse)
- Find elements out of range (recycle candidates)
- Assign recycled elements to new indices
- Update positions with transform
- Hide off-screen elements
4. Performance Checklist
- ✅ Use
requestAnimationFrame for scroll throttling
- ✅ Add
{ passive: true } to scroll listeners
- ✅ Use
transform instead of top/left for positioning
- ✅ Implement overscan buffer to prevent flickering
- ✅ Skip updates when visible range hasn't changed
- ✅ Use absolute positioning for precise control
- ✅ Recycle DOM elements instead of creating/destroying
- ✅ Provide cleanup callback for event listeners
When to Use Virtual Lists
✅ Use When:
- Rendering 1,000+ items
- Items have uniform/predictable height
- Scrolling performance is critical
- Memory constraints are a concern
- Data is paginated or infinite
❌ Avoid When:
- Lists are small (<100 items)
- Items have highly variable heights
- Complex nested scrolling is required
- You need to support keyboard navigation to all items
- CSS grid/flexbox layouts are essential
Common Pitfalls
-
Forgetting to clean up event listeners → Memory leaks
- Solution: Use
onRecycle callback
-
Not using passive listeners → Janky scrolling
- Solution:
{ passive: true }
-
Synchronous expensive rendering → Dropped frames
- Solution: Keep
renderItem fast, defer heavy work
-
Variable item heights without measurement → Misaligned items
- Solution: Use fixed heights or implement height caching
-
Not handling resize events → Broken layout
- Solution: Add ResizeObserver or window resize listener
Advanced Enhancements
Dynamic Height Support
private heightCache = new Map<number, number>();
private measureItem(index: number, element: HTMLElement): number {
if (!this.heightCache.has(index)) {
const height = element.getBoundingClientRect().height;
this.heightCache.set(index, height);
}
return this.heightCache.get(index)!;
}
Sticky Headers
private sectionHeaders = new Map<number, string>();
private updateStickyHeader(): void {
const scrollTop = this.viewport.scrollTop;
const currentSection = this.getSectionAtOffset(scrollTop);
this.stickyHeaderElement.textContent = currentSection;
}
Bidirectional Scrolling
private updateVisibleRange2D(): void {
const scrollX = this.viewport.scrollLeft;
const scrollY = this.viewport.scrollTop;
const colStart = Math.floor(scrollX / this.itemWidth);
const rowStart = Math.floor(scrollY / this.itemHeight);
}
Testing Strategies
describe('SimpleVirtualList', () => {
it('renders only visible items', () => {
const list = new SimpleVirtualList({...});
expect(list.getScrollInfo().visibleEnd - list.getScrollInfo().visibleStart)
.toBeLessThan(50);
});
it('recycles elements on scroll', () => {
const recycledIndices: number[] = [];
const list = new SimpleVirtualList({
onRecycle: (el) => recycledIndices.push(parseInt(el.dataset.index!)),
...
});
list.scrollToIndex(1000);
expect(recycledIndices.length).toBeGreaterThan(0);
});
it('handles rapid scrolling', async () => {
const list = new SimpleVirtualList({...});
for (let i = 0; i < ; i++) {
list.(i * );
();
}
});
});
Summary
Virtual scrolling is a critical technique for rendering large datasets efficiently. The key principles are:
- Only render what's visible - Create DOM nodes for viewport + buffer only
- Recycle aggressively - Reuse existing elements instead of creating new ones
- Use spacers for height - Maintain scroll position without rendering all items
- Optimize scroll handling - Use RAF throttling and passive listeners
- Position with transforms - Leverage GPU acceleration
- Clean up properly - Prevent memory leaks with recycle callbacks
This implementation can handle millions of items with smooth 60fps scrolling and minimal memory usage.