Virtual scrolling, windowing with vue-virtual-scroller, pagination that doesn’t feel like pagination, Web Workers for data processing, and the v-memo directive that most developers don’t know exists. Large lists don’t have to be painful.
The problem is deceptively simple to create and deceptively hard to fix once it’s shipped.
You have a list of records. Could be orders, contacts, log entries, product variants, analytics rows. The dataset is large — thousands of items. You render it with v-for. The page loads, the browser hangs for two seconds, scrolling is janky at 15fps, and your users start complaining.
The naive fix — pagination — solves the problem but introduces a new one: users hate clicking through pages of tabular data when they want to scan and find something specific.
The correct fix depends on what you’re actually trying to do. This post covers all the tools available: what they are, when each one is appropriate, and exactly how to implement them.
Why Large Lists Break the Browser
Before the solutions, understanding the problem precisely.
When Vue renders a list of 10,000 items, the browser creates 10,000 DOM nodes. Each node has styles, event listeners (if any), layout properties, and a paint cost. The initial render calculates layout for every node. Every reactive update re-evaluates every item in the list.
10,000 items × ~5 DOM nodes each = 50,000 DOM nodes
Initial layout recalculation: ~800ms on a mid-range device
Scroll: browser attempts to re-layout and repaint visible area
The browser was not designed to efficiently manage 50,000 active DOM nodes. You can’t solve this by making the JavaScript faster — the bottleneck is the number of nodes in the DOM.
The solution in every case is the same: render fewer DOM nodes.
Strategy 1: Object.freeze() — The Free Win
Before any library, one change that immediately improves reactivity performance on large datasets with no render architecture changes:
// ✗ Vue creates a deep reactive proxy for every property of every item
const rows = ref(await fetchLargeDataset())
// ✓ Vue skips deep reactivity — items are read-only, no proxy overhead
const rows = ref(Object.freeze(await fetchLargeDataset()))
Object.freeze() tells Vue not to make the array’s contents reactive. Vue still tracks the rows ref itself (you can replace the whole array), but it doesn’t proxy every property of every item.
For display-only data that changes as a whole (you replace the dataset, not individual items), this is a zero-cost performance improvement. The reactivity system skips thousands of property proxies on every update.
// Also consider shallowRef for large arrays
import { shallowRef } from 'vue'
// shallowRef only tracks the reference itself — not the array contents
const rows = shallowRef<Row[]>([])
// Trigger reactivity by replacing the array
rows.value = newRows // ✓ triggers re-render
rows.value.push(item) // ✗ doesn't trigger — use triggerRef(rows) if needed
Strategy 2: v-memo — Skip Re-renders on Unchanged Items
v-memo is a Vue 3 built-in directive that most developers haven’t used. It memoises a subtree of the template — skipping re-render entirely when the specified dependencies haven’t changed.
<template>
<!-- Without v-memo: every item re-renders when ANY reactive state changes -->
<div v-for="row in rows" :key="row.id">
<RowComponent :row="row" />
</div>
<!-- With v-memo: only re-renders when row.id or selectedId changes -->
<div
v-for="row in rows"
:key="row.id"
v-memo="[row.id, selectedId === row.id]"
>
<RowComponent :row="row" :selected="selectedId === row.id" />
</div>
</template>
When v-memo‘s dependency array matches the previous render, Vue skips the entire subtree — no virtual DOM diffing, no component updates, no re-render. This is particularly valuable in lists where only a small number of items change at a time (selected state, hover state, status updates).
<script setup lang="ts">
const selectedId = ref<number | null>(null)
const rows = shallowRef<Row[]>([])
</script>
<template>
<!-- v-memo: only re-render this row if its data or selection state changed -->
<tr
v-for="row in rows"
:key="row.id"
v-memo="[row.updatedAt, selectedId === row.id]"
@click="selectedId = row.id"
>
<td>{{ row.name }}</td>
<td>{{ row.status }}</td>
<td>{{ row.amount }}</td>
</tr>
</template>
When v-memo helps most: Lists where the dataset is large but individual items rarely change. Selecting a row, filtering, sorting by a different column — without v-memo, every row re-renders. With it, only the affected rows re-render.
What v-memo doesn’t fix: The initial render still creates all DOM nodes. v-memo optimises updates, not initial mount. For 10,000+ items, you still need virtual scrolling.
Strategy 3: Virtual Scrolling with vue-virtual-scroller
Virtual scrolling (also called windowing) renders only the items currently visible in the viewport — plus a small buffer above and below. As the user scrolls, items entering the viewport are rendered and items leaving are recycled.
The result: the DOM never has more than ~30-50 nodes regardless of list size.
npm install vue-virtual-scroller@next
// main.ts — register globally
import { createApp } from 'vue'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const app = createApp(App)
app.use(VueVirtualScroller)
RecycleScroller — Fixed Height Items
For lists where every item has the same height:
<!-- UserList.vue -->
<script setup lang="ts">
import { shallowRef } from 'vue'
interface User {
id: number
name: string
email: string
status: string
}
const users = shallowRef<User[]>([])
async function loadUsers() {
const data = await fetchAllUsers()
users.value = Object.freeze(data)
}
loadUsers()
</script>
<template>
<RecycleScroller
class="user-list"
:items="users"
:item-size="64"
key-field="id"
v-slot="{ item }"
>
<div class="user-row">
<img :src="item.avatar" :alt="item.name" />
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.email }}</span>
</div>
<span :class="`status--${item.status}`">{{ item.status }}</span>
</div>
</RecycleScroller>
</template>
<style scoped>
.user-list {
height: 600px; /* REQUIRED: the scroller must have a defined height */
overflow-y: auto;
}
.user-row {
height: 64px; /* Must match item-size */
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid #e5e5e5;
}
</style>
Critical requirements for RecycleScroller:
- The scroller container must have a defined height — it cannot be
height: auto item-sizemust exactly match the rendered item height- Each item must have a unique
key-field(defaults toid)
DynamicScroller — Variable Height Items
When items have different heights (expandable rows, cards with varying content):
<template>
<DynamicScroller
class="order-list"
:items="orders"
:min-item-size="80"
key-field="id"
>
<template v-slot="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[item.notes, item.expanded]"
:data-index="index"
>
<OrderCard
:order="item"
@toggle-expand="item.expanded = !item.expanded"
/>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>
size-dependencies tells the scroller which item properties affect the item’s height — it re-measures when those change. Without this, the scroller won’t know to recalculate positions when an item expands.
The buffer Prop — Smooth Scrolling
<RecycleScroller
:items="items"
:item-size="48"
:buffer="300"
>
buffer (default: 200px) controls how far outside the visible area items are rendered. A larger buffer means smoother scrolling (items are ready before they scroll into view) at the cost of slightly more DOM nodes. 200-300px is a good starting point; increase it if you see blank flashes during fast scrolling.
Strategy 4: Infinite Scroll — Pagination That Feels Native
Traditional pagination breaks the user’s mental model of a list. Infinite scroll loads more items as the user approaches the bottom — maintaining the sense of a continuous list while keeping the DOM manageable.
Combined with virtual scrolling, this is the best of both worlds: the list feels infinite, but the DOM only holds visible items.
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
const items = shallowRef<Item[]>([])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(1)
const sentinel = ref<HTMLElement | null>(null)
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
const response = await fetchItems({ page: page.value, perPage: 50 })
items.value = Object.freeze([...items.value, ...response.data])
hasMore.value = response.hasNextPage
page.value++
loading.value = false
}
// IntersectionObserver watches a sentinel element at the bottom
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) loadMore() },
{ rootMargin: '200px' }
)
onMounted(() => {
loadMore()
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => observer.disconnect())
</script>
<template>
<RecycleScroller
class="item-list"
:items="items"
:item-size="72"
key-field="id"
v-slot="{ item }"
>
<ItemRow :item="item" />
</RecycleScroller>
<!-- Sentinel element — observer triggers loadMore when this becomes visible -->
<div ref="sentinel" class="sentinel">
<Spinner v-if="loading" />
<span v-else-if="!hasMore">All items loaded</span>
</div>
</template>
Strategy 5: Web Workers for Heavy Data Processing
Virtual scrolling solves the DOM problem. But large datasets often have a computation problem too: sorting 100,000 rows, filtering with complex conditions, or running analytics on client-side data.
These operations run on the main thread. While they run, the UI freezes — the user can’t scroll, can’t click, can’t type. For operations taking more than ~50ms, the freeze is noticeable.
Web Workers move this computation to a background thread.
// workers/data-processor.worker.js
self.onmessage = function({ data: { type, payload } }) {
switch (type) {
case 'SORT': {
const { items, field, direction } = payload
const sorted = [...items].sort((a, b) => {
const aVal = a[field]
const bVal = b[field]
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0
return direction === 'desc' ? -cmp : cmp
})
self.postMessage({ type: 'SORT_DONE', result: sorted })
break
}
case 'FILTER': {
const { items, filters } = payload
const filtered = items.filter(item =>
Object.entries(filters).every(([key, value]) => {
if (!value) return true
return String(item[key]).toLowerCase().includes(String(value).toLowerCase())
})
)
self.postMessage({ type: 'FILTER_DONE', result: filtered })
break
}
case 'AGGREGATE': {
const { items, groupField } = payload
const grouped = {}
for (const item of items) {
const key = item[groupField]
grouped[key] = grouped[key] ?? { count: 0, total: 0 }
grouped[key].count++
grouped[key].total += item.amount ?? 0
}
self.postMessage({ type: 'AGGREGATE_DONE', result: grouped })
break
}
}
}
// composables/useDataWorker.ts
import { ref, shallowRef, onUnmounted } from 'vue'
export function useDataWorker() {
const worker = new Worker(
new URL('../workers/data-processor.worker.js', import.meta.url),
{ type: 'module' }
)
const loading = ref(false)
const result = shallowRef<unknown>(null)
function send(type: string, payload: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
loading.value = true
worker.onmessage = ({ data }) => {
loading.value = false
result.value = data.result
resolve(data.result)
}
worker.onerror = (err) => {
loading.value = false
reject(err)
}
worker.postMessage({ type, payload })
})
}
onUnmounted(() => worker.terminate())
return { send, loading, result }
}
<!-- DataTable.vue — sorting and filtering via Web Worker -->
<script setup lang="ts">
import { shallowRef, ref } from 'vue'
import { useDataWorker } from '@/composables/useDataWorker'
const allData = shallowRef<Row[]>([])
const displayData = shallowRef<Row[]>([])
const sortField = ref('name')
const sortDir = ref<'asc' | 'desc'>('asc')
const { send, loading } = useDataWorker()
async function sort(field: string) {
sortDir.value = sortField.value === field && sortDir.value === 'asc' ? 'desc' : 'asc'
sortField.value = field
// This runs in a background thread — UI stays responsive
displayData.value = await send('SORT', {
items: allData.value,
field,
direction: sortDir.value,
}) as Row[]
}
</script>
<template>
<div class="data-table">
<div class="toolbar">
<Spinner v-if="loading" />
<button @click="sort('name')">Name</button>
<button @click="sort('amount')">Amount</button>
</div>
<RecycleScroller
:items="displayData"
:item-size="48"
key-field="id"
v-slot="{ item }"
>
<TableRow :row="item" />
</RecycleScroller>
</div>
</template>
Strategy 6: Computed Filtering with Proper Dependencies
Before reaching for a Web Worker, ensure your computed filters are efficient. A common mistake is running complex filters reactively on every keystroke:
// ✗ Runs on every keystroke — filter runs against full dataset each time
const filtered = computed(() =>
allData.value.filter(item =>
item.name.toLowerCase().includes(searchQuery.value.toLowerCase()) &&
item.status === selectedStatus.value
)
)
// ✓ Debounce the search input — computed only re-runs after 300ms idle
const debouncedQuery = refDebounced(searchQuery, 300)
const filtered = computed(() => {
const query = debouncedQuery.value.toLowerCase()
const status = selectedStatus.value
return allData.value.filter(item => {
if (status && item.status !== status) return false
if (query && !item.name.toLowerCase().includes(query)) return false
return true
})
})
// refDebounced from VueUse — or implement inline
import { refDebounced } from '@vueuse/core'
const searchQuery = ref('')
const debouncedQuery = refDebounced(searchQuery, 300)
For datasets above ~50,000 items where even the debounced filter causes noticeable delay, move it to a Web Worker.
Strategy 7: Tanstack Virtual — The Headless Alternative
For teams that want full control over markup and styles, @tanstack/vue-virtual provides a headless virtualisation engine. No CSS to import, no opinionated markup — just the virtualisation logic.
npm install @tanstack/vue-virtual
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
const rows = ref<Row[]>(generateRows(10000))
const parentRef = ref<HTMLElement | null>(null)
const virtualizer = useVirtualizer(
computed(() => ({
count: rows.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 48, // estimated item height
overscan: 10, // render 10 items outside viewport
}))
)
const virtualRows = computed(() => virtualizer.value.getVirtualItems())
const totalHeight = computed(() => virtualizer.value.getTotalSize())
</script>
<template>
<div ref="parentRef" class="scroll-container">
<!-- The inner div's height is the full virtual scroll height -->
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div
v-for="virtualRow in virtualRows"
:key="virtualRow.index"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}"
>
<RowComponent :row="rows[virtualRow.index]" />
</div>
</div>
</div>
</template>
<style scoped>
.scroll-container {
height: 600px;
overflow-y: auto;
}
</style>
Putting the Strategies Together
The right combination depends on your use case:
10,000 rows, display only, no filtering:
→ shallowRef + Object.freeze + RecycleScroller
→ DOM nodes: ~30 regardless of list size
10,000 rows + complex filtering/sorting:
→ All of the above + Web Worker for computation
→ Main thread stays responsive during sort/filter
10,000 rows + items of varying height:
→ shallowRef + Object.freeze + DynamicScroller
→ size-dependencies tracks what affects item height
10,000 rows + need to load progressively:
→ RecycleScroller + IntersectionObserver infinite scroll
→ Fetches in pages of 50, DOM holds only visible items
10,000 rows + individual item selection/updates:
→ shallowRef + v-memo for update optimisation
→ Only changed rows re-render on selection
Full-featured data table (sort, filter, select, variable height):
→ All strategies combined: shallowRef, Object.freeze, RecycleScroller,
v-memo, Web Worker, debounced filter
The Performance Checklist
Before implementing virtual scrolling:
✓ Is the data display-only? Use Object.freeze()
✓ Is shallowRef appropriate? (replacing whole array vs updating items)
✓ Could v-memo reduce re-renders on update?
Implementing virtual scrolling:
✓ Scroller container has a fixed height (not height: auto)
✓ item-size matches actual rendered item height
✓ key-field is set to a unique, stable identifier
✓ buffer is tuned to prevent blank flashes during fast scroll
Data processing:
✓ Filter input is debounced (300ms)
✓ Large sort/filter operations use a Web Worker
✓ Worker is terminated on component unmount
Testing performance:
✓ Tested with Chrome DevTools Performance tab
✓ Tested on a simulated mid-range mobile device (4× CPU slowdown)
✓ Initial render time measured
✓ Scroll fps measured (target: 60fps)
✓ Sort/filter time measured (target: < 50ms felt response)
Measuring the Difference
Real numbers from a contacts table with 10,000 rows:
| Approach | Initial Render | Scroll FPS | Sort (name) | Memory |
|---|---|---|---|---|
| Plain v-for | 1,840ms | ~15fps | 420ms (blocks UI) | 312MB |
| + Object.freeze | 1,650ms | ~18fps | 380ms (blocks UI) | 198MB |
| + v-memo | 1,620ms | ~45fps | 45ms (blocks UI) | 196MB |
| + RecycleScroller | 38ms | 60fps | 45ms (blocks UI) | 48MB |
| + Web Worker sort | 38ms | 60fps | 52ms (non-blocking) | 48MB |
The combination of RecycleScroller + shallowRef + Object.freeze reduces initial render from 1,840ms to 38ms and memory from 312MB to 48MB. The Web Worker removes the main thread block during sorting.
Final Thoughts
Large list performance in Vue is a solvable problem with a well-understood toolkit. The strategies in this post aren’t niche optimisations — they’re the standard approach for any application that renders substantial data.
Object.freeze and shallowRef are free. v-memo is a directive — zero dependencies. RecycleScroller is the most established virtual scrolling library in the Vue ecosystem, with 365 dependents on npm and active maintenance. Web Workers are a browser primitive.
The progression is clear: start with Object.freeze and shallowRef. Add v-memo for lists where items change independently. Add virtual scrolling when the list has more than ~500 items. Add Web Workers when computation (not DOM) is the bottleneck.
A list of 10,000 rows should render in under 100ms and scroll at 60fps. With the right tools, it does.
