Virtual Scrolling — Complete Guide
In this tutorial, you will learn about Virtual Scrolling. We cover key concepts, practical examples, and best practices to help you master this topic.
Virtual scrolling renders only the visible portion of a large list, recycling DOM nodes as the user scrolls to achieve smooth performance with thousands of items.
What You'll Learn
- How virtual scrolling works and why it is necessary for large lists
- How to calculate which items are visible based on scroll position
- How to position items correctly using padding or transform
- How to implement a basic virtual scroller from scratch
Why It Matters
Rendering 100,000 DOM nodes freezes the browser. Even 5,000 nodes causes noticeable jank on scroll. Virtual scrolling maintains a constant small number of DOM nodes (30-50) regardless of list size, enabling smooth scrolling with millions of items.
Real-World Use
- Google Drive renders thousands of files with virtual scrolling
- Slack's channel list uses virtual scrolling for performance
- VS Code's file explorer virtualizes its tree view
flowchart LR A[Total Items: 100000] --> B[Scroll Position Changes] B --> C[Calculate Visible Range] C --> D[startIndex, endIndex] D --> E[Render visible items] D --> F[Offset with top padding] E --> G[Recycle old nodes] G --> H[Update content] H --> I[Scroll feels smooth]
Understanding Virtual Scrolling
Instead of rendering all items, render only those visible plus a buffer above and below.
class VirtualScroller {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.buffer = 5; // Extra items above/below viewport
this.visibleItems = new Map(); // index -> DOM element
// Setup container
this.container.style.overflow = 'auto';
this.container.style.position = 'relative';
this.container.style.height = '400px';
// Create inner div for content
this.content = document.createElement('div');
this.content.style.position = 'relative';
this.container.appendChild(this.content);
// Set total height
this.totalHeight = items.length * itemHeight;
this.content.style.height = this.totalHeight + 'px';
// Listen to scroll
this.container.addEventListener('scroll', () => {
requestAnimationFrame(() => this.render());
});
// Initial render
this.render();
}
render() {
const scrollTop = this.container.scrollTop;
const viewportHeight = this.container.clientHeight;
// Calculate visible range
const startIndex = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - this.buffer);
const endIndex = Math.min(this.items.length,
Math.ceil((scrollTop + viewportHeight) / this.itemHeight) + this.buffer);
// Remove items that are no longer visible
for (const [index, element] of this.visibleItems) {
if (index < startIndex || index >= endIndex) {
element.remove();
this.visibleItems.delete(index);
}
}
// Add new visible items
for (let i = startIndex; i < endIndex; i++) {
if (!this.visibleItems.has(i)) {
const element = this.createItemElement(i);
element.style.position = 'absolute';
element.style.top = (i * this.itemHeight) + 'px';
element.style.height = this.itemHeight + 'px';
element.style.left = '0';
element.style.right = '0';
this.content.appendChild(element);
this.visibleItems.set(i, element);
}
}
}
createItemElement(index) {
const item = this.items[index];
const div = document.createElement('div');
div.textContent = `${index}: ${item.name}`;
div.className = 'virtual-item';
return div;
}
}
// Usage
const items = Array.from({ length: 100000 }, (_, i) => ({
name: `Item ${i}`
}));
const scroller = new VirtualScroller(
document.querySelector('#virtual-container'),
items,
30 // 30px per item
);
Expected output: The container shows only ~20 items at a time (viewport + buffer). As the user scrolls, visible items update. The total DOM node count stays constant.
Recycling DOM Nodes
For even better performance, recycle existing nodes instead of creating new ones.
class RecyclingScroller {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.pool = [];
this.activeElements = new Map();
this.container.style.overflow = 'auto';
this.container.style.height = '500px';
this.content = document.createElement('div');
this.container.appendChild(this.content);
this.content.style.height = (items.length * itemHeight) + 'px';
this.content.style.position = 'relative';
this.container.addEventListener('scroll', () => {
requestAnimationFrame(() => this.render());
});
this.render();
}
getElementFromPool() {
if (this.pool.length > 0) {
return this.pool.pop();
}
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.left = '0';
el.style.right = '0';
el.className = 'recycled-item';
return el;
}
returnElementToPool(element) {
element.remove();
this.pool.push(element);
}
render() {
const scrollTop = this.container.scrollTop;
const viewportHeight = this.container.clientHeight;
const buffer = 3;
const startIndex = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - buffer);
const endIndex = Math.min(this.items.length,
Math.ceil((scrollTop + viewportHeight) / this.itemHeight) + buffer);
// Recycle old elements
for (const [index, element] of this.activeElements) {
if (index < startIndex || index >= endIndex) {
this.returnElementToPool(element);
this.activeElements.delete(index);
}
}
// Reuse recycled elements for new visible items
for (let i = startIndex; i < endIndex; i++) {
if (!this.activeElements.has(i)) {
const element = this.getElementFromPool();
const item = this.items[i];
element.textContent = `${i}: ${item.name} (recycled)`;
element.style.top = (i * this.itemHeight) + 'px';
element.style.height = this.itemHeight + 'px';
this.content.appendChild(element);
this.activeElements.set(i, element);
}
}
}
}
// Pool size: ~25 elements regardless of total items
console.log('Recycling scroller created');
Expected output: The recycling scroller creates exactly enough DOM nodes to fill the viewport plus buffer. When items scroll out of view, their elements are repurposed for incoming items. No new DOM nodes are created during scrolling.
Dynamic Item Heights
Not all items have the same height. Handle variable heights with a more complex approach.
class VariableHeightScroller {
constructor(container, items) {
this.container = container;
this.items = items;
this.heights = items.map((item) => this.estimateHeight(item));
this.positions = this.calculatePositions();
this.visibleElements = new Map();
this.pool = [];
this.container.style.overflow = 'auto';
this.container.style.height = '500px';
this.content = document.createElement('div');
this.content.style.position = 'relative';
this.container.appendChild(this.content);
const totalHeight = this.positions[this.positions.length - 1];
this.content.style.height = totalHeight + 'px';
this.container.addEventListener('scroll', () => {
requestAnimationFrame(() => this.render());
});
this.render();
}
estimateHeight(item) {
// Estimate based on content
return item.height || 60; // Default if not specified
}
calculatePositions() {
const positions = [0];
for (let i = 0; i < this.heights.length; i++) {
positions.push(positions[i] + this.heights[i]);
}
return positions;
}
findIndex(scrollTop) {
// Binary search for the item at scrollTop
let low = 0;
let high = this.positions.length - 1;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (this.positions[mid] < scrollTop) {
low = mid + 1;
} else {
high = mid;
}
}
return Math.max(0, low - 1);
}
render() {
const scrollTop = this.container.scrollTop;
const viewportHeight = this.container.clientHeight;
const buffer = 3;
const startIndex = Math.max(0,
this.findIndex(scrollTop) - buffer);
let endIndex = startIndex;
const maxBottom = scrollTop + viewportHeight + buffer * 60;
while (endIndex < this.items.length &&
this.positions[endIndex] < maxBottom) {
endIndex++;
}
// Remove out-of-range items
for (const [index, el] of this.visibleElements) {
if (index < startIndex || index >= endIndex) {
el.remove();
this.pool.push(el);
this.visibleElements.delete(index);
}
}
// Add visible items
for (let i = startIndex; i < endIndex; i++) {
if (!this.visibleElements.has(i)) {
const el = this.pool.pop() || document.createElement('div');
el.textContent = `${i}: ${this.items[i].name}`;
el.style.position = 'absolute';
el.style.top = this.positions[i] + 'px';
el.style.height = this.heights[i] + 'px';
el.style.left = '0';
el.style.right = '0';
this.content.appendChild(el);
this.visibleElements.set(i, el);
}
}
}
}
// Usage with varying heights
const mixedItems = Array.from({ length: 10000 }, (_, i) => ({
name: `Item ${i}`,
height: 30 + (i % 5) * 15 // Heights from 30 to 90 pixels
}));
Expected output: Items with different heights are positioned correctly. The virtual scroller accounts for the cumulative positions. Scrolling works smoothly because only visible items are rendered.
Handling Empty States and Loading
A robust virtual scroller needs to handle edge cases.
function setupVirtualScroll(config) {
const { container, fetchPage, itemHeight, pageSize } = config;
const state = {
items: [],
loading: false,
hasMore: true,
error: null
};
const scroller = {
container,
itemHeight,
visibleElements: new Map(),
pool: [],
content: document.createElement('div')
};
scroller.content.style.position = 'relative';
scroller.container.appendChild(scroller.content);
// Loading indicator
const loadingEl = document.createElement('div');
loadingEl.textContent = 'Loading...';
loadingEl.style.display = 'none';
scroller.container.appendChild(loadingEl);
// Empty state
const emptyEl = document.createElement('div');
emptyEl.textContent = 'No items to display';
emptyEl.style.display = 'none';
scroller.container.appendChild(emptyEl);
async function loadNextPage() {
if (state.loading || !state.hasMore) return;
state.loading = true;
loadingEl.style.display = 'block';
try {
const newItems = await fetchPage(
Math.floor(state.items.length / pageSize),
pageSize
);
if (newItems.length === 0) {
state.hasMore = false;
} else {
state.items = state.items.concat(newItems);
scroller.content.style.height =
(state.items.length * itemHeight) + 'px';
render();
}
} catch (err) {
state.error = err;
console.error('Failed to load page:', err);
} finally {
state.loading = false;
loadingEl.style.display = 'none';
emptyEl.style.display =
state.items.length === 0 ? 'block' : 'none';
}
}
function render() {
// ... same as recycling scroller render logic ...
}
scroller.container.addEventListener('scroll', () => {
requestAnimationFrame(render);
// Load more when near bottom
const nearBottom = scroller.container.scrollTop +
scroller.container.clientHeight >=
scroller.container.scrollHeight - 200;
if (nearBottom && !state.loading && state.hasMore) {
loadNextPage();
}
});
loadNextPage();
return scroller;
}
Expected output: The virtual scroller shows a loading indicator while fetching, displays empty state when no items exist, and loads more items as the user scrolls near the bottom. Error states are handled gracefully.
Common Mistakes
- Not accounting for scrollbar width — The scrollbar takes space. Use
overflow: autoand account for the scrollbar width in measurements. - Forgetting to update total height when items change — If items are added, removed, or resized, recalculate and update the content container's height.
- Using transform for positioning instead of top — Both work, but transform may cause sub-pixel rendering issues.
toppositioning is more reliable for virtual scroll. - Not recycling DOM nodes — Creating new elements on every scroll triggers Garbage Collection and causes jank. Recycle nodes from a pool.
- Ignoring the resize case — If the viewport height changes (mobile rotation, panel resize), recalculate the visible range.
Practice Questions
- What is the key insight that makes virtual scrolling performant? Only rendering the visible subset of items plus a small buffer, keeping the total DOM node count constant regardless of list size.
- Why is node recycling important for virtual scrolling? It avoids creating and destroying DOM nodes during scroll, preventing garbage collection pauses and maintaining smooth 60fps.
- How do you handle variable item heights in a virtual scroller? Maintain an array of heights and calculate cumulative positions. Use binary search to find which items are visible at the current scroll position.
- Challenge: Implement a virtual scroller that supports insert and delete operations. When an item is inserted at position 500, update all subsequent positions without recreating all elements.
FAQ
Mini Project
Build a file browser that uses virtual scrolling. Display 100,000 files with columns for name, size, modified date, and type. Each row is 36px high. Implement click-to-select with highlighted rows. Support keyboard navigation (ArrowUp, ArrowDown). Show the total file count and "Showing X to Y of Z" indicator. Use node recycling for optimal performance.
What's Next
Continue with Lesson 26: Shadow DOM Introduction to learn how Shadow Dom provides style and DOM Encapsulation for custom components.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro