10,000 Rows Without Performance Issues
Rendering 10,000 DOM elements at once brings any browser to a crawl. Virtual scrolling solves the problem: only the visible elements actually exist in the DOM, while everything else is simulated with correctly sized placeholders. The result is a smooth scrollbar with no performance loss, achievable with Alpine.js and the native IntersectionObserver.
Table of Contents
- 1. The DOM Performance Problem with Large Lists
- 2. The Windowing Concept: Render Only What Is Visible
- 3. IntersectionObserver and Sentinel Elements as Scroll Triggers
- 4. Placeholder Technique: Simulating Correct Scrollbar Height
- 5. The Visible Window: Calculating startIndex and endIndex
- 6. Alpine Integration: x-for with a Computed Subset
- 7. Variable Row Heights: ResizeObserver and Dynamic Measuring
- 8. Search and Filtering in Virtual Lists
- 9. Comparison: Virtual Scroll vs. Pagination vs. Infinite Scroll
- 10. Summary
- 11. FAQ
1. The DOM Performance Problem with Large Lists
The browser does not render a list of 10,000 entries in one step. It creates a DOM node for every element, calculates layout and style for each one, and keeps all of them in memory even when they sit outside the visible area. For plain text elements that is still tolerable, but as soon as each element contains images, buttons and several nested divs, the overhead adds up drastically. Layouts with large product lists, order histories or log viewers quickly reach the point where scrolling stutters and the browser thread blocks.
The core problem is layout thrashing: the browser has to know the position of every visible element, and if elements outside the visible area belong to the same layout context, they all have to be calculated too. 10,000 elements at 60px each create a scrollable area of 600,000px, and the browser has to know this entire height and recalculate every intermediate position while scrolling. Virtual scrolling decouples the actual DOM size from the perceived size of the list.
A practical benchmark: a product list with 50 visible entries feels just as smooth to scroll as one with 10,000 entries, provided virtual scrolling is implemented correctly. Memory usage no longer grows linearly with list length but stays constant at the size of the window. Especially on mobile devices with limited RAM and a slower JS thread, that is the difference between a usable and an unusable interface.
2. The Windowing Concept: Render Only What Is Visible
Windowing, also called virtualization, is the principle of keeping only the list items visible in the viewport actually present in the DOM. Every invisible item is replaced by a single placeholder whose height equals the sum of the non-rendered items. The user sees a normal scrollbar because the container has the correct total height, but in reality only 20 to 40 DOM elements exist at any given moment, regardless of the total length of the list.
The algorithm continuously calculates two indices: startIndex, the first item to render, and endIndex, the last one. Everything before startIndex is replaced by a top spacer with height startIndex * itemHeight, and everything after endIndex by a bottom spacer sized to the remaining items. The visible window is typically a bit larger than the viewport: you render an overscan of 3 to 5 items above and below the visible area so no gaps appear while scrolling.
// Virtual scroll state model: core algorithm
function virtualScroll() {
return {
allItems: [], // Full dataset, never rendered all at once
itemHeight: 64, // Fixed row height in pixels
overscan: 5, // Extra items above/below viewport
scrollTop: 0,
containerHeight: 600, // Visible area height
get startIndex() {
return Math.max(0, Math.floor(this.scrollTop / this.itemHeight) - this.overscan);
},
get endIndex() {
const visible = Math.ceil(this.containerHeight / this.itemHeight);
return Math.min(this.allItems.length - 1, this.startIndex + visible + this.overscan * 2);
},
get visibleItems() {
return this.allItems.slice(this.startIndex, this.endIndex + 1);
},
get topSpacerHeight() {
return this.startIndex * this.itemHeight;
},
get bottomSpacerHeight() {
return Math.max(0, (this.allItems.length - this.endIndex - 1) * this.itemHeight);
},
get totalHeight() {
return this.allItems.length * this.itemHeight;
}
};
}
3. IntersectionObserver and Sentinel Elements as Scroll Triggers
The classic approach to virtual scrolling uses the container's scroll event. The problem: the scroll event fires extremely often. At 60fps scrolling that is 60 events per second, each one with a DOM layout access via scrollTop. Even with debouncing, noticeable delays occur. The more modern approach uses the IntersectionObserver with sentinel elements: invisible marker divs at the start and end of the rendered items that the observer watches.
When a sentinel element enters the viewport, it means the user has scrolled to the edge of the currently rendered range and new items need to be loaded. This is conceptually clean and fast: the observer runs on a separate thread and needs no access to scrollTop. The granularity is coarser than with the scroll event, but for infinite scroll and page-boundary-based loading of blocks it is the cleaner solution.
4. Placeholder Technique: Simulating Correct Scrollbar Height
The scrollbar height of an element is proportional to the ratio of viewport height to total content height. For the scrollbar to correctly represent the full list length, the container content must have the total height of all items, even when only a fraction of them is rendered. That is achieved with two spacer divs with calculated heights: a top spacer before the visible items and a bottom spacer after them, both with an explicit pixel height.
The critical moment is updating these spacers while scrolling. Because Alpine is reactive, updating scrollTop is enough. Alpine automatically recalculates topSpacerHeight, bottomSpacerHeight and visibleItems as computed properties and re-renders the template. That does assume, however, that the scroll event correctly updates the Alpine data. With @scroll.passive="scrollTop = $event.target.scrollTop" on the container element, that happens efficiently without blocking scrolling.
<!-- Virtual scroll container with Alpine -->
<div
x-data="virtualScroll()"
x-init="
// Generate 10000 demo items
allItems = Array.from({ length: 10000 }, (_, i) => ({
id: i + 1,
name: 'Eintrag #' + (i + 1),
sku: 'SKU-' + String(i + 1).padStart(5, '0'),
price: (Math.random() * 200 + 5).toFixed(2)
}));
// Measure container height after render
$nextTick(() => {
containerHeight = $el.clientHeight;
});
"
class="overflow-y-auto border border-slate-200 rounded-xl"
style="height: 600px;"
@scroll.passive="scrollTop = $event.target.scrollTop"
>
<!-- Inner container with total virtual height -->
<div :style="'height:' + totalHeight + 'px; position: relative;'">
<!-- Top spacer -->
<div :style="'height:' + topSpacerHeight + 'px;'"></div>
<!-- Only visible items rendered -->
<template x-for="item in visibleItems" :key="item.id">
<div class="flex items-center px-4 border-b border-slate-100" :style="'height:' + itemHeight + 'px;'">
<span class="w-16 text-slate-400 text-xs font-mono" x-text="item.id"></span>
<span class="flex-1 font-medium text-slate-800" x-text="item.name"></span>
<span class="w-32 text-slate-500 text-sm font-mono" x-text="item.sku"></span>
<span class="w-20 text-right font-semibold text-teal-700" x-text="'€ ' + item.price"></span>
</div>
</template>
<!-- Bottom spacer: no explicit div needed, inner height covers it -->
</div>
</div>
5. The Visible Window: Calculating startIndex and endIndex
Calculating startIndex and endIndex is the heart of the windowing algorithm. startIndex = Math.floor(scrollTop / itemHeight) - overscan figures out the first item to render: how many full row heights fit into the current scroll offset? The result minus the overscan is the first visible index. An overscan of 5 to 10 items ensures that no blank areas appear while scrolling before Alpine has updated the DOM.
endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + overscan * 2 adds the number of visible rows plus double the overscan to the start index. Both indices need to be clamped with Math.max(0, ...) and Math.min(allItems.length - 1, ...) to prevent array overflows. With fixed-height items, the calculation is O(1) and extremely fast. With variable heights, a position map must be built, which is covered in section 7.
6. Alpine Integration: x-for with a Computed Subset
Alpine's x-for iterates over visibleItems, the computed subset of the full array. That works directly because visibleItems is defined as an Alpine computed getter and is recalculated automatically whenever scrollTop, startIndex or allItems changes. Alpine's diffing algorithm then only updates the DOM elements that actually changed. During continuous scrolling, that is usually just 1 to 2 new elements being shown and hidden.
The :key binding on item.id is especially important for virtual scrolling. Without correct keys, Alpine would re-render every visible element on each scroll step because it could not track item identity. With :key="item.id", Alpine recognizes that, for example, only the first and last element of the visible set have changed, and patches only those two DOM nodes. That drastically reduces render overhead during fast scrolling.
7. Variable Row Heights: ResizeObserver and Dynamic Measuring
The simplest form of virtual scrolling assumes fixed, uniform row heights. In practice, lists often have variable heights: product tiles with different image sizes, comments with variable text length, order lines with multiple products. For variable heights, the algorithm needs to build a position map: an array that stores, for every index, the cumulative height of all preceding items.
Building this map is expensive upfront. With 10,000 items, all 10,000 heights must be known before anything is rendered. The pragmatic solution: initialize all items with an estimated height (e.g. 60px), render the first visible elements, and measure their actual height with ResizeObserver or, after rendering, with getBoundingClientRect(). The position map is then corrected dynamically, which can cause small jumps in scrollbar position, a known trade-off in virtual scroll with variable heights that nearly every library, including TanStack Virtual, handles the same way.
// Scroll-to-index: programmatic navigation in virtual scroll
function virtualScrollWithJump() {
return {
// ... base properties from previous example
scrollToIndex(index) {
const targetScrollTop = index * this.itemHeight;
this.$el.scrollTop = targetScrollTop;
this.scrollTop = targetScrollTop;
},
scrollToItem(id) {
const index = this.allItems.findIndex(item => item.id === id);
if (index !== -1) this.scrollToIndex(index);
},
// Infinite load: append new items when nearing bottom
get isNearBottom() {
return this.scrollTop + this.containerHeight >= this.totalHeight - this.itemHeight * 10;
},
async loadMore() {
if (this.isLoading || !this.hasMore) return;
this.isLoading = true;
const newItems = await fetch('/api/items?offset=' + this.allItems.length + '&limit=50').then(r => r.json());
this.allItems = [...this.allItems, ...newItems.items];
this.hasMore = newItems.hasMore;
this.isLoading = false;
},
isLoading: false,
hasMore: true
};
}
8. Search and Filtering in Virtual Lists
Search and filtering in virtually scrolling lists require special attention: the filtered result array replaces the full array as the basis for the windowing algorithm. When the user types a search term, filteredItems is recalculated as a computed property, and visibleItems slices the visible window out of filteredItems. The scroll offset must be reset to 0 whenever the filter changes, otherwise the window might show an empty area if the filtered result is shorter than the current scroll offset.
For large datasets, the filter function should not run on every keystroke. A debounce of 150 to 300ms on the search field prevents 10,000 items from being filtered on every keystroke while typing fast. Alpine has no built-in debounce for this, but a simple clearTimeout(this._debounce); this._debounce = setTimeout(() => this.applyFilter(), 200) inside the method is enough. For especially large datasets, filtering in a Web Worker is recommended to avoid blocking the main thread.
9. Comparison: Virtual Scroll vs. Pagination vs. Infinite Scroll
The three common solutions for large datasets have fundamentally different UX characteristics and technical requirements. Pagination splits data into fixed pages, simple to implement, but the user loses context on every page change and cannot scroll continuously. Infinite scroll loads new data once the user reaches the bottom of the page, which feels fluid but suffers from a growing DOM size after many load operations. Virtual scrolling keeps the DOM size constant but requires a known or measurable dataset size and fixed or measurable item heights.
| Approach | DOM Size | UX Continuity | Implementation |
|---|---|---|---|
| Pagination | Minimal (1 page) | Context lost on page change | Very simple |
| Infinite Scroll | Grows unbounded | Fluid | Medium |
| Virtual Scroll (fixed height) | Constant (~20-40 DOM nodes) | Fully scrollable | Medium complexity |
| Virtual Scroll (variable height) | Constant | Fully scrollable | Complex (position map) |
| Hybrid: Virtual + Infinite Load | Constant | Fluid and complete | Medium complexity |
For Magento product lists with a known dataset size, virtual scrolling with a fixed row height is the cleanest solution: constant DOM size, full scroll navigation and no page changes. Pagination remains the better choice for search engine indexing, because products on paginated URLs are discoverable. The hybrid approach, virtual scrolling as a frontend window over an infinite-load backend, combines the performance benefits of both worlds and suits large catalogs without a known total size upfront.
Mironsoft
Performance optimization for Magento stores and Alpine.js frontends
Need to display large data lists smoothly?
We implement virtual scrolling, lazy loading and pagination strategies for Magento product lists, admin grids and order histories, without external libraries, directly with Alpine.js.
Performance Analysis
Measure and fix DOM size and layout bottlenecks in product lists and admin grids
Virtual Scroll
Windowing implementation for product catalogs with fixed and variable item heights
Infinite Load
Backend pagination with IntersectionObserver trigger and Alpine state for smooth loading
10. Summary
Virtual scrolling with Alpine.js solves the DOM performance problem in large lists without an external library. The algorithm calculates startIndex and endIndex from the current scrollTop value and the row height, and x-for renders only the visibleItems subset. Two spacer divs with calculated heights simulate the correct scrollbar position for the full list. The scroll event updates scrollTop, and Alpine reactively recalculates all dependent values.
An implementation with fixed row heights is achievable in about 50 lines of Alpine code and clearly beats a simple x-for iteration in both performance and scroll comfort for lists over 500 elements. Variable row heights require a position map that is updated dynamically with ResizeObserver. Search and filtering replace the base array with a filtered subset and reset the scroll offset. The hybrid approach with infinite loading enables virtual scrolling over datasets whose total size is not known upfront.
Virtual Scrolling with Alpine.js: The Essentials at a Glance
Core Algorithm
startIndex = floor(scrollTop / itemHeight) - overscan. endIndex = startIndex + ceil(containerHeight / itemHeight) + overscan*2. Clamp both with min/max.
Scrollbar Simulation
Top spacer with startIndex * itemHeight px. Total container height allItems.length * itemHeight px. Bottom spacer follows automatically.
Alpine Integration
x-for over visibleItems with :key="item.id". @scroll.passive updates scrollTop. Computed getters for all derived values.
Search and Filtering
Filtered subset as the basis for windowing. Reset scroll offset to 0 when the filter changes. Debounce 150 to 300ms on search input.