Virtual Scrolling Isn't Always Faster: Benchmarks at 10K and 100K Rows
theme: vuepress highlight: a11y-dark
Preface
In daily development, lists are almost the most common page form.
Order lists, log lists, message lists, product lists, approval records, monitoring alerts—these pages all look ordinary, but to make the page operate smoothly, developers often silently make many optimizations in terms of performance.
There are many ways to optimize list performance, such as front-end pagination, back-end pagination, virtual scrolling, infinite scrolling, lazy loading, skeleton screens, preloading, caching, and so on. Today, the star of the show is "virtual scrolling" .
As we all know, when a browser processes a list, it needs to create DOM nodes, calculate styles, layout, paint, and so on. If you look at a single row in a list, its structure is not complex, but what often causes page scrolling to stutter is the geometric increase in the number of list items. Virtual scrolling emerged precisely to solve the rendering pressure of the DOM.
This article will mainly focus on the following questions.
- Implementing a fixed-height virtual list from scratch.
- Is virtual scrolling definitely better performing than normal scrolling?
- In what scenarios is virtual scrolling suitable? How much performance can it improve?
If you are already familiar with the implementation of virtual scrolling, then skip directly to the performance comparison section!
Implementing a Normal Scrolling List
Before implementing a virtual scrolling list, let's pretend to hand-code a normal scrolling list.
Friendly reminder: The following code is written in
vue3. As for whyvue3? Because I believe in light!!!
Step 1: Mock Data
interface ListItem {
id: number
title: string
status: string
}
const total = 100000
const listData: ListItem[] = Array.from({ length: total }, (_, index) => ({
id: index + 1,
title: `Order Rendering Task ${index + 1}`,
status: index % 3 === 0 ? 'Pending' : index % 3 === 1 ? 'In Progress' : 'Completed',
}))
First, generate a hundred thousand data entries to test whether I need to replace my computer.
Step 2: Construct the DOM
<section class="list-card">
<header class="list-header">
<div>
<h2>Normal List</h2>
<p>Renders complete data at once</p>
</div>
<span>{{ listData.length }} nodes</span>
</header>
<div class="list-view" :style="{ height: `${viewHeight}px` }">
<ul class="list-body">
<li v-for="item in listData" :key="item.id" class="list-item">
<strong>#{{ item.id }}</strong>
<span>{{ item.title }}</span>
<em>{{ item.status }}</em>
</li>
</ul>
</div>
</section>
Step 3: Write the CSS
.list-card {
border: 1px solid #d8e1ee;
border-radius: 8px;
background: #fff;
padding: 20px;
box-shadow: 0 18px 40px rgb(33 56 96 / 10%);
}
.list-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.list-header h2,
.list-header p {
margin: 0;
}
.list-header h2 {
font-size: 22px;
font-weight: 700;
}
.list-header p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
}
.list-header span {
color: #dc2626;
font-size: 14px;
}
.list-view {
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.list-body {
margin: 0;
padding: 0 8px;
list-style: none;
}
.list-item {
display: grid;
grid-template-columns: 90px 1fr 82px;
align-items: center;
box-sizing: border-box;
height: 56px;
margin-bottom: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
padding: 0 16px;
}
.list-item strong {
color: #0f172a;
}
.list-item span {
min-width: 0;
overflow: hidden;
color: #475569;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-item em {
justify-self: end;
color: #0f766e;
font-style: normal;
}
If you have read this far and don't use ai, congratulations, you have beaten 90% of non-front-end personnel.
Implementing a Virtual Scrolling List
The page for virtual scrolling is pretty much the same as the normal page, after all, they were both written by the same ai.
It also has 100000 data entries, but the number of actually rendered nodes is only around twenty.
This is the most intuitive benefit of virtual scrolling: the scrollbar looks like a complete list, but the number of DOM nodes is much smaller.
Step 1: Construct the DOM
The mock data writing method here is the same as above.
A most basic virtual list usually requires a three-layer structure:
list-view Scroll container, responsible for generating scrollTop
list-space Placeholder container, responsible for propping up the full list height
list-body Real list, only renders data near the current visible area
The corresponding code is as follows:
<section class="list-card">
<header class="list-header">
<div>
<h2>Virtual List</h2>
<p>Only renders data near the viewport</p>
</div>
<span>{{ showData.length }} nodes</span>
</header>
<div class="list-view" :style="{ height: `${viewHeight}px` }" @scroll="onScroll">
<div class="list-space" :style="{ height: `${fullHeight}px` }">
<ul class="list-body" :style="{ transform: `translateY(${moveY}px)` }">
<li v-for="item in showData" :key="item.id" class="list-item">
<strong>#{{ item.id }}</strong>
<span>{{ item.title }}</span>
<em>{{ item.status }}</em>
</li>
</ul>
</div>
</div>
</section>
There are two key points here:
<div class="list-space" :style="{ height: `${fullHeight}px` }"></div>
list-space is responsible for propping up the full height, making the scrollbar behave as if 10000 data entries really exist.
And the truly rendered data comes from:
<li v-for="item in showData" :key="item.id" class="list-item"></li>
showData is just a small slice of the complete data.
Step 2: Write the CSS
.list-view {
height: 520px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.list-space {
position: relative;
}
.list-body {
position: absolute;
top: 0;
right: 0;
left: 0;
margin: 0;
padding: 8px;
list-style: none;
will-change: transform;
}
.list-item {
display: grid;
grid-template-columns: 90px 1fr 82px;
align-items: center;
box-sizing: border-box;
height: 56px;
margin-bottom: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
padding: 0 16px;
}
The most important thing to note here is the single row height.
In the example, the actual height occupied by each row is:
Content height 56px + margin-bottom 8px = 64px
So the script will define:
const rowHeight = 64
Fixed-height virtual lists rely heavily on this value. If the real row height does not match rowHeight, the greater the scroll distance, the more obvious the position deviation will be.
Step 3: Write the JS
1. Listen for scroll position
const scrollTop = ref(0)
const onScroll = (e: Event) => {
scrollTop.value = (e.target as HTMLElement).scrollTop
}
When the user scrolls the list, we get the current scroll distance, and then calculate which segment of data should be rendered based on this value.
2. Calculate the full height
const fullHeight = total * rowHeight
fullHeight is the theoretical height of the complete list.
For example:
100000 * 64 = 6400000px
The page will not actually render 100000 DOM nodes, but the scrollbar needs to know how high the complete list should be.
This is the role of list-space: it is not responsible for displaying content, only for creating the full scroll height.
3. Calculate the start index start
const start = computed(() => Math.max(Math.floor(scrollTop.value / rowHeight) - buffer, 0))
start indicates from which data index the rendering should currently begin. Ignoring buffer for now, just look at this part:
Math.floor(scrollTop.value / rowHeight)
Assume:
scrollTop = 640
rowHeight = 64
Then:
640 / 64 = 10
This indicates that the top of the current viewport has roughly scrolled to around index 10.
The outer Math.max(..., 0) is to prevent the result from becoming negative. Because the initial scroll distance is 0, if buffer is subtracted later, it might result in a negative number, so the minimum value must be limited to 0.
4. Calculate the visible count showCount
const showCount = computed(() => Math.ceil(viewHeight / rowHeight))
showCount indicates at least how many data entries the viewport itself needs to render.
In the current example:
viewHeight = 520
rowHeight = 64
The calculation result is:
520 / 64 = 8.125
That is to say, 8 complete data entries can be seen in the viewport, and a part of the 9th entry will also be exposed.
So Math.ceil is used here to round up:
showCount = 9
5. Calculate the end index end
const end = computed(() => Math.min(showCount.value + start.value + buffer * 2, total))
end indicates the end position of the current data slice.
If there were no buffer, the end position would roughly be:
start + showCount
But to make scrolling smoother, we usually render a few more data entries above and below the visible area, so here we add:
buffer * 2
Math.min(..., total) is to prevent the end index from exceeding the total data length.
6. Slice the data to be rendered
const showData = computed(() => listData.slice(start.value, end.value))
showData is the data currently actually rendered onto the page.
Assume:
start = 4
end = 25
Then only the following will be rendered in the real DOM:
Index 4 to Index 24
Although the total data has 10000 entries, there are only about twenty <li> elements on the page at this time.
Virtual Scrolling Considerations
1. Why is moveY needed?
When understanding virtual lists, moveY is the place where people most easily get stuck.
First, look at the code:
const moveY = computed(() => start.value * rowHeight)
It corresponds to the template:
<ul class="list-body" :style="{ transform: `translateY(${moveY}px)` }"></ul>
Its role is: to move this small segment of currently rendered DOM to the position it should appear in the complete list.
Why move it?
Because slice deletes the preceding data.
For example, ignoring buffer for now, assume:
scrollTop = 640px
rowHeight = 64px
start = 10
At this time, the real position of the data entry at index 10 in the complete list should be:
10 * 64 = 640px
But after executing:
listData.slice(10, end)
The data entry at index 10 will become the first item in showData.
Without moveY, this data entry would start rendering from the top of list-body, which is the 0px position.
This creates a misalignment:
The data is already index 10
But the position is still that of index 0
So we need:
moveY = start * rowHeight
To move this segment of DOM down by 640px.
The illustration is as follows:
Summarized in one sentence:
sliceis responsible for reducing the number ofDOMnodes, andmoveYis responsible for compensating for the spatial position removed byslice.
Without moveY, content misalignment, blank spaces, or even jittering will occur during scrolling. The root cause is not frequent DOM updates, but that the updated DOM has not stood back at the correct height position in the complete list.
2. What is the role of buffer?
buffer is the buffer zone.
const buffer = 6
Its role is: to render a few extra data entries above and below the viewport, to avoid brief blank spaces appearing during fast scrolling.
Assume the top of the current viewport has scrolled to around index 10.
Without a buffer:
start = 10
That is, rendering starts exactly from index 10.
If set:
buffer = 6
Then:
start = 10 - 6 = 4
That is to say, although the user is currently actually seeing around index 10, the DOM will start rendering in advance from index 4.
Combined with:
const end = computed(() => Math.min(showCount.value + start.value + buffer * 2, total))
Assume:
showCount = 9
start = 4
buffer = 6
Then:
end = 4 + 9 + 12 = 25
The actual rendering range is:
Index 4 to Index 24
It can be understood as:
Index 4 - 9: Buffer above the viewport
Index 10 - 18: Current visible area
Index 19 - 24: Buffer below the viewport
The benefit of doing this is smoother scrolling, especially when the scrolling speed is relatively fast and the list item content is relatively complex, users are less likely to see blank areas.
However, buffer is not the bigger the better. The larger it is, the more extra DOM nodes are rendered; the smaller it is, the easier it is for white space to appear during fast scrolling. In actual projects, it can be adjusted based on list complexity and device performance.
Complete Code
Performance Comparison
Both were recorded for about 30 seconds under the condition of clearing the cache and reloading.
Normal Scrolling Performance for 100,000 Data Entries
Rendering:5763msScripting:5721msSystem:4874msPainting:1650ms
Virtual Scrolling Performance for 100,000 Data Entries
Rendering:710msScripting:592msSystem:757msPainting:449ms
Core Conclusion:
- The main problem with normal lists is: it renders all 100,000 rows at once. Each row has multiple nodes, actually producing hundreds of thousands of
DOM-related nodes. The browser has to createDOM, calculate styles, layout, and paint, creating immense pressure, resulting in noticeable stuttering during loading and scrolling. - Although virtual lists constantly update the
DOMduring scrolling, they always keep only about twenty-something nodes, so their initial rendering is much faster, memory pressure is much smaller, and the list is very stable during scrolling.
🤔Possible Questions?
1. In the normal scrolling code, the js part only has the mock data section, why is the Scripting time still longer than that of virtual scrolling?
Answer: Having such a question likely overlooks that Vue templates ultimately become JS render functions, meaning <li v-for="item in listData" :key="item.id"> becomes listData.map(item => createVNode('li', ...)). The creation of 100,000 vnodes, patching 100,000 vnodes, calling DOM API to create real nodes, setting class / text / attributes, and inserting them into the page all fall under Scripting.
After comparing 100,000 data entries, what happens if the data is compressed to 10,000 entries? This time, both were also recorded for over 30 seconds.
Normal Scrolling Performance for 10,000 Data Entries
Rendering:521msScripting:221msSystem:1358msPainting:755ms
Virtual Scrolling Performance for 10,000 Data Entries
Rendering:913msScripting:710msSystem:592msPainting:602ms
Core Conclusion:
- Both were relatively smooth during scrolling, with no stuttering.
- From the data above, it can be seen that virtual lists have higher
Scriptingtime. This is because virtual lists need to constantly calculatestart/end/showData/moveYduring scrolling and update the visibleDOM, so theJSand partial rendering costs are higher.
Applicable Scenarios
Scenarios for Virtual Scrolling:
- Very large data volumes, such as 50,000, 100,000, or hundreds of thousands of entries (combined with back-end pagination).
- Complex
DOMstructure for each row, such as having images, buttons, tags, components, icons. - Weak device performance, tight memory.
- Fixed list item heights, or predictable heights.
Scenarios for Normal Scrolling:
- Only a few hundred or a few thousand simple data entries.
- Users will not scroll deeply.
- Row heights vary greatly and are difficult to calculate in advance.
- List items have complex internal states, where
DOMreuse can easily cause state cross-talk.
As for "how much performance can be improved," a fixed value cannot be given; it depends on the data volume and row complexity. Your two sets of tests can probably be summarized as follows:
For 10,000 simple data entries, both have their own pros and cons, and both can hold up overall. Virtual lists do not necessarily win on all Performance metrics. If the data volume is further reduced, the advantages of normal lists become more obvious. Putting aside factors like computer configuration and the framework itself, it can be simply concluded that 10,000 simple data entries is the watershed between the two, but virtual lists have a very high upper limit, while their lower limit might be slightly lower than normal scrolling.
Additional Notes
In real business scenarios, if the data volume is very large, virtual scrolling is usually used in conjunction with back-end pagination or scroll loading. Front-end virtual scrolling is responsible for reducing DOM, and back-end pagination is responsible for reducing one-time transmission and memory pressure.
Finally, let's throw out a question: if the row heights are not fixed, vary greatly, and the data volume is also large, how can virtual scrolling be implemented?
If this article was helpful to you, welcome to like, bookmark, and also welcome to share your thoughts in the comments section.
I am Mh, a developer who continuously learns front-end and likes to break down problems to explain them clearly.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
👍