Large Tables in Vue: Rendering Ten Thousand Rows Without a Frozen Tab

A 50,000-row dataset freezes a Vue app long before the database complains. Practical patterns for big lists and tables: server-side pagination first, virtualization where it's actually justified, and reactivity habits that keep rendering cheap.

The tab locks up before the database notices

Here's the failure we see in reviews most often: the API returns every row "so the user can search and filter on the client", the Vue component renders all of it, and a 40,000-record table turns the browser into a slideshow. The blame is lopsided more often than not. The query is fine, the server responds in 200ms, and most of the freeze happens in the renderer: 40,000 component instances, 200,000 DOM nodes, and a reactivity system watching every one of them.

Frontend rendering has a cost per row, and no amount of tuning makes 40,000 rows free. The fixes keep those rows out of the DOM.

Server-side pagination is the default, not an optimization

The first question to ask on any listing screen is what the user can actually read. Almost always it's a page of 50 rows plus a way to jump to what they want. That maps directly to server-side pagination:

  • The query takes page and per_page, returns one slice plus a total count for the pager.
  • Filters and sorting run in SQL, with the indexes backing them. Filtering 40,000 rows in the browser wastes the database's index either way.
  • "Load more" or numbered pages both work; number pagination suits admin tables where users jump around, load-more suits feeds.

Pushing this to the server feels less flexible than shipping all data and filtering locally, and the flexibility is an illusion: keeping a client-side copy in sync with the server still requires a refetch, so you pay the complexity of two sources of truth to avoid writing a WHERE clause.

There's one honest exception: small, bounded reference lists (statuses, categories, a few hundred options) are fine on the client. Bounded is the operative word.

Virtualization, only where the use case demands it

Some screens genuinely need a wall of rows on screen at once, usually a log viewer, message history, or a table someone scrolls for minutes. Virtualization renders only the rows inside the visible viewport plus a small buffer, so a million rows cost the DOM about 30:

<script setup>
import { DynamicScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>

<template>
  <DynamicScroller :items="rows" :min-item-height="48" key-field="id">
    <template #default="{ item }">
      <TableRow :row="item" />
    </template>
  </DynamicScroller>
</template>

For a simple uniform-row table, a hand-rolled slice of about 30 lines (container with a spacer div of the full scroll height, absolutely-positioned viewport slice) works too and adds no dependency.

The fiddly parts are never the core: variable row heights need measurement, a sticky header needs to live outside the virtualized container, keyboard scrolling and find-in-page behave differently, and tests have to interact with a component that only renders what's visible. That cost is worth paying for a log viewer. It is not worth paying so a reports table can "support" 20,000 rows nobody scrolls through, especially when a CSV export serves the actual need of working with all the data.

Reactivity habits that hurt even medium-sized tables

A table of 2,000 rows shouldn't be slow, and usually the slowdown is self-inflicted:

  • :key must be a stable database id, not the array index. Keying by index makes Vue patch rows in place when the list changes, which shows up as edited values appearing in the wrong row after a sort or filter.
  • Big immutable payloads don't need deep reactivity. shallowRef(rows) skips walking tens of thousands of properties to make them reactive; the array reference still triggers updates.
  • Computed properties over the whole dataset run on every dependency change, including every keystroke in a search box. Debounce the input, or move the search server-side like everything else.
  • A watch with deep: true on a large array fires traversal on every change and is rarely what the author intended. Watch the specific query state, not the result set.

Test against production-shaped data

Every one of these bugs survives a staging environment with 12 test rows. Generating 50,000 rows before a table ships is five minutes of work with a seeder or a faker loop, and it answers the important questions: does scrolling stay smooth, does filtering respond while the result count is displayed, does sorting a rendered column feel instant. If a feature only works at demo-data volume, that's a fact worth knowing before a client finds it.