Pagination Versus Virtualization for Large Tables

Permalink to "Pagination Versus Virtualization for Large Tables"

When a table has more rows than the browser and screen readers can comfortably handle, there are two standard answers. Pagination shows a fixed number of rows per page, with controls to move between pages; every row on the current page is fully in the DOM. Virtualization renders only the rows near the viewport as the user scrolls a single long list; rows far away do not exist in the DOM. Both keep the accessibility tree small. They differ sharply in what screen reader and keyboard users can do.

This page compares them on the dimensions that matter for accessibility and gives a decision rule. It belongs to DOM size limits and accessible performance tradeoffs.

Spec reference

Permalink to "Spec reference"

Neither pattern is required by WCAG; both can conform. The criteria each must satisfy differ:

  • Pagination — SC 2.4.3 Focus Order after a page change, SC 4.1.3 for announcing the new range, SC 1.3.1 for the current-page state (aria-current="page").
  • Virtualization — SC 1.3.1 for honest size and position (aria-rowcount/aria-rowindex or aria-setsize/aria-posinset), SC 2.1.1 for keyboard movement that renders off-screen rows, SC 2.4.3 for keeping the focused row alive.

The key behavioural fact is about screen reader browse mode: NVDA’s and JAWS’s virtual cursors read the DOM. They cannot scroll a virtualized container to make the browser render more rows, so browse-mode reading stops at the edge of the rendered window. In a paginated table, browse mode reads the whole page.

Pagination and virtualization compared Matrix comparing pagination and virtualization across browse-mode reading, table commands, find in page, position announcements, focus handling and implementation effort. Pagination and virtualization comparedDimensionPaginationVirtualizationBrowse-mode readingWhole page readableRendered rows onlyTable commands(Ctrl+Alt+Arrow)Work on the pageOnly in rendered windowFind in pageCurrent pageRendered rows onlyPosition ("row 4,032 of12,480")Needs page contextWith aria-rowindexFocus handlingSimple: caption after pagingMust keep focused row renderedScanning speed for sightedusersPage by pageContinuous scroll
Pagination favours reading; virtualization favours continuous, widget-driven navigation.

When to use each

Permalink to "When to use each"

Pagination for static, read-mostly tables: reports, search results, audit logs that users read, lists users compare row by row. Screen reader users keep every tool they know — browse mode, table navigation, find — and page changes are explicit, announced events. Choose a generous page size (50–100 rows) so paging is not constant.

Virtualization for interactive widgets where your code owns navigation anyway: a role="grid" with cell focus, a listbox of records, a log viewer with keyboard stepping. Users move with arrow keys and Page Up/Page Down inside the widget, which you implement to render and focus rows on demand.

Both when the data set is enormous and the table is interactive: paginate into large pages of, say, 1,000 rows, and virtualize within each page. Or offer a paginated “reading view” and a virtualized “working view” of the same data.

The misapplication to name is virtualizing a read-only report because a component library made it the default. Screen reader users open the report, start reading, and the table apparently ends after thirty rows.

Annotated code example

Permalink to "Annotated code example"
// A decision helper you can put in a component's docs or lint rule
function chooseLargeTableStrategy({ rows, interactiveCells, primaryReading }) {
  if (rows <= 500) return 'render all';                    // small: no trade-off needed
  if (primaryReading === 'browse' && !interactiveCells) {
    return rows <= 100000 ? 'paginate (50–100 per page)' : 'paginate + server filtering';
  }
  if (interactiveCells) return 'virtualize with aria-rowcount/rowindex and focus retention';
  return 'paginate, with an optional virtualized working view';
}
<!-- Paginated reading view: whole page in the DOM, honest range -->
<table aria-describedby="range">
  <caption>Audit log</caption> … 100 rows …
</table>
<p id="range">Showing 401 to 500 of 12,480 entries</p>
<nav aria-label="Audit log pages">… aria-current="page" …</nav>

<!-- Virtualized working view: small DOM, full-size metadata -->
<div role="grid" aria-label="Audit log" aria-rowcount="12481">   <!-- +1 header row -->
  <div role="row" aria-rowindex="1">…column headers…</div>
  <div role="row" aria-rowindex="4033">…</div>                    <!-- only ~30 rows rendered -->
</div>

In the paginated table, row positions within the full set come from the range text (“401 to 500 of 12,480”), not from aria-rowindex — a static table’s rows are all present, so the browser’s own count (“row 23 of 101”) is correct for the page, and the range text supplies the global context.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Task Paginated table Virtualized grid
Read everything linearly Page by page with browse mode Arrow through the widget; slower, but possible
Jump to the end Last page link, then Ctrl+End Ctrl+End in the grid
Find a value Find in page (current page) or app search App search only
Hear total size Range summary: “of 12,480” “row 4,033 of 12,481” on each row
After a change (sort) Back to page 1; focus caption; announce Focus stays; announce; rows re-render
Paginate or virtualize? Decision tree for a large table: small tables render everything, read-mostly static tables paginate, interactive grids virtualize, and very large interactive data sets combine both. Paginate or virtualize?Do users mostly read the rows, or operate a widget?Read (static table)Paginate50–100 rows per page; announcerangesOperate (grid, listbox)Virtualizerowcount, rowindex, focus retentionBoth, huge dataCombinelarge pages, virtualized within
Start from how users read, then from how big the data is.

Integration context

Permalink to "Integration context"

Building the paginated option well is covered across pagination & result-set navigation, including announcing page changes in paginated tables. The virtualized option is in accessible virtualized list patterns, with a concrete implementation in making TanStack Virtual lists and tables accessible.

“Load more” and infinite scroll are a third family with their own problems; accessible load more versus infinite scroll covers why infinite scroll in particular is hard to make accessible.

The reading view and the working view Comparison of offering the same data as a paginated reading view and as a virtualized working view. The reading view and the working view✓ Reading view (paginated)Static table, browse mode everywhere100 rows per page, range announcedFind in page works per pageBest for review and comparison✓ Working view (virtualized)Grid with cell focus and editingContinuous scroll, small DOMPosition announced per rowBest for triage and data entry
When users both read and operate, offering both views is often better than compromising one.

Gotchas

Permalink to "Gotchas"

Tiny pages. Ten rows per page to “keep it fast” makes screen reader users page constantly. Measure; 50–100 rows is usually fine.

Sort and filter across pages. Pagination must sort and filter the full data set server-side, not just the current page, or results are misleading.

Virtualized tables without row counts. Without aria-rowcount, readers announce “row 12 of 31” in a 12,000-row table — the rendered window size. Always set it.

Design system notes

Permalink to "Design system notes"

Expose the choice explicitly in the data table component — mode: 'paginated' | 'virtual' — with paginated as the default for static tables and virtual available only when the table is a grid. Document the decision rule beside the prop, so the accessible choice is the obvious one.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Is pagination or virtualization more accessible?

For static tables that users read with screen reader table commands, pagination is usually more accessible, because every row on a page is in the DOM. For interactive grids where your code controls navigation, virtualization works well if position metadata and focus retention are implemented.

Why can't screen readers read a virtualized table in browse mode?

Browse mode reads the DOM, and virtualized rows that are off-screen do not exist in the DOM. The virtual cursor cannot trigger the scrolling that would render them.

How many rows should a paginated table show per page?

Usually 50 to 100. Very small pages force constant paging; very large pages can slow screen readers. Measure with your real content.

Can I combine pagination and virtualization?

Yes. For very large interactive data sets, paginate into large pages and virtualize within each page, or offer a paginated reading view alongside a virtualized working view.

Permalink to "Related"

← Back to DOM Size Limits and Accessible Performance Tradeoffs