跪拜 Guibai
← Back to the summary

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.

  1. Implementing a fixed-height virtual list from scratch.
  2. Is virtual scrolling definitely better performing than normal scrolling?
  3. 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.

image.png

Friendly reminder: The following code is written in vue3. As for why vue3? 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.

image.png

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:

image.png

Summarized in one sentence:

slice is responsible for reducing the number of DOM nodes, and moveY is responsible for compensating for the spatial position removed by slice.

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

image.png

  1. Rendering: 5763ms
  2. Scripting: 5721ms
  3. System: 4874ms
  4. Painting: 1650ms

Virtual Scrolling Performance for 100,000 Data Entries

image.png

  1. Rendering: 710ms
  2. Scripting: 592ms
  3. System: 757ms
  4. Painting: 449ms

Core Conclusion:

  1. 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 create DOM, calculate styles, layout, and paint, creating immense pressure, resulting in noticeable stuttering during loading and scrolling.
  2. Although virtual lists constantly update the DOM during 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

image.png

  1. Rendering: 521ms
  2. Scripting: 221ms
  3. System: 1358ms
  4. Painting: 755ms

Virtual Scrolling Performance for 10,000 Data Entries

image.png

  1. Rendering: 913ms
  2. Scripting: 710ms
  3. System: 592ms
  4. Painting: 602ms

Core Conclusion:

  1. Both were relatively smooth during scrolling, with no stuttering.
  2. From the data above, it can be seen that virtual lists have higher Scripting time. This is because virtual lists need to constantly calculate start / end / showData / moveY during scrolling and update the visible DOM, so the JS and partial rendering costs are higher.

Applicable Scenarios

Scenarios for Virtual Scrolling:

  1. Very large data volumes, such as 50,000, 100,000, or hundreds of thousands of entries (combined with back-end pagination).
  2. Complex DOM structure for each row, such as having images, buttons, tags, components, icons.
  3. Weak device performance, tight memory.
  4. Fixed list item heights, or predictable heights.

Scenarios for Normal Scrolling:

  1. Only a few hundred or a few thousand simple data entries.
  2. Users will not scroll deeply.
  3. Row heights vary greatly and are difficult to calculate in advance.
  4. List items have complex internal states, where DOM reuse 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.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

廾匸22

👍