Measuring Accessibility Tree Cost for Large Tables

Permalink to "Measuring Accessibility Tree Cost for Large Tables"

Every DOM element that is exposed to assistive technology becomes a node in the browser’s accessibility tree, and every change to the DOM is mirrored there and sent to screen readers as events. For a 5,000-row table with eight columns, that is over 40,000 cell nodes — plus their text — that the browser must maintain and the screen reader must ingest into its own buffer. The result is a class of performance problem sighted users never see: NVDA taking several seconds to respond after a sort, JAWS freezing while it rebuilds its virtual buffer, VoiceOver lagging behind every keypress.

This page shows how to measure that cost so decisions about pagination, virtualization and DOM budgets are based on numbers. It belongs to DOM size limits and accessible performance tradeoffs.

Spec reference

Permalink to "Spec reference"

There is no WCAG criterion for performance. The cost shows up against functional criteria instead: SC 2.1.1 Keyboard when the page becomes unresponsive to keys, SC 4.1.3 when status messages arrive too late to be meaningful, and SC 2.2.1 Timing Adjustable when session timers run out while a screen reader is still processing.

Measurement tools:

  • Chrome DevTools Accessibility pane with “Enable full-page accessibility tree” shows the tree; the Performance panel records “Accessibility” tasks on the main thread in traces.
  • CDP Accessibility.getFullAXTree returns every node; its length is the node count.
  • Playwright page.accessibility.snapshot({ interestingOnly: false }) gives a similar count (deprecated in newer versions in favour of ARIA snapshots, which omit some nodes — use CDP for counts).
  • Manual screen reader timing: time from keypress to first speech, repeated and averaged.
Accessibility nodes by table size, 8 columns Bar chart of approximate accessibility tree nodes for a table with 8 columns as the row count grows, counting row, cell and text nodes. Accessibility nodes by table size, 8 columns100 rows1700 nodes1,000 rows17000 nodes5,000 rows85000 nodes — buffer rebuilds become noticeable20,000 rows340000 nodes — screen readers stall
Roughly 17 nodes per row (a row, 8 cells, 8 text leaves) — so node count grows linearly and quickly.

When to measure — and when not to bother

Permalink to "When to measure — and when not to bother"

Measure any table that can exceed a few hundred rows, any table that updates while users read it (live data, streaming), and any page that combines several large tables. Measure before and after introducing virtualization or pagination, so the change can be justified.

A static 50-row table does not need this. The accessibility tree cost is trivial, and effort is better spent on semantics.

The misapplication to name is measuring only Lighthouse’s “Avoid an excessive DOM size” audit. It counts DOM elements, not accessibility nodes or update cost, and it does not see the time a screen reader spends rebuilding its buffer — the part users actually wait for.

Annotated code example

Permalink to "Annotated code example"
// Playwright + CDP: count accessibility nodes and time an update
import { test, expect } from '@playwright/test';

test('invoice table accessibility cost', async ({ page }) => {
  await page.goto('/invoices?rows=5000');
  const cdp = await page.context().newCDPSession(page);
  await cdp.send('Accessibility.enable');

  const { nodes } = await cdp.send('Accessibility.getFullAXTree');
  const ignored = nodes.filter((n) => n.ignored).length;
  const exposed = nodes.length - ignored;
  console.log({ total: nodes.length, exposed });            // record per row count

  // Time a sort, including the accessibility work on the main thread
  await page.evaluate(() => performance.mark('sort-start'));
  await page.getByRole('button', { name: 'Amount' }).click();
  await page.getByRole('status').filter({ hasText: 'Sorted by Amount' }).waitFor();
  const ms = await page.evaluate(() => {
    performance.mark('sort-end');
    return performance.measure('sort', 'sort-start', 'sort-end').duration;
  });

  // Budget: exposed nodes and time-to-status (see the CI budget guide)
  expect(exposed).toBeLessThan(20000);
  expect(ms).toBeLessThan(500);
});
Manual screen reader timing protocol (per AT, per row count):
1. Load the page; wait for the reader to finish initial reading.
2. Focus the Amount sort button.
3. Start a timer on Enter; stop it at the first spoken word of the result.
4. Repeat 5 times; record the median.
5. Repeat for 100, 1,000 and 5,000 rows.

The automated numbers — node count and main-thread time — are proxies. The manual timing is the real user experience, because the screen reader’s own processing happens outside the browser and does not appear in any browser trace. Do the manual protocol at least once per major table design; use the automated proxies in CI.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Row count Typical symptom by ear What is happening
Hundreds None Tree small; updates instant
Low thousands Short pause after sort or filter Browser updates tree; reader re-syncs its buffer
~5,000+ Seconds of silence; keys queue up NVDA/JAWS rebuild virtual buffers; VoiceOver lags
Tens of thousands Reader unresponsive; may crash Buffer size and event flood exceed reader limits

These bands are indicative, not thresholds: cell complexity (links, buttons, nested elements) multiplies node counts, and older hardware lowers every band. That is exactly why you measure your own tables.

A measurement session Steps of a measurement session: build test pages at several row counts, count nodes with CDP, trace an update, time the screen reader by hand, and record results in a table. A measurement sessionBuild variants100, 1,000 and 5,000 rows of realistic datasame cell markup as productionCount nodesCDP getFullAXTree; exposed versus ignoredautomatedTrace an updatesort or filter with a Performance recordingautomatedTime the readerkeypress to first speech, median of fivemanual, per ATRecorda small table per releasetrend over time
Three row counts are enough to see the curve; the manual timing is the number to trust.

Integration context

Permalink to "Integration context"

Once you have numbers, enforce them: setting a DOM node budget in CI turns the node count into a failing check. When a table exceeds its budget, the choice between paging and virtualizing is covered in pagination versus virtualization for large tables.

Rendering shortcuts like content-visibility: auto reduce layout and paint work but do not reduce accessibility tree size in the way many teams assume — see content-visibility: auto and screen readers.

What each measurement tells you Comparison of DOM element counts and Lighthouse audits against accessibility node counts and manual screen reader timing. What each measurement tells you✗ Partial proxiesNothing about buffer rebuilds✓ Accessibility-specificCDP exposed node countMain-thread time for a real updateKeypress-to-speech timing per readerTracked per row count over releases
The first two are cheap and partial; the last two are what screen reader users experience.

Gotchas

Permalink to "Gotchas"

Ignored nodes. getFullAXTree includes ignored nodes (hidden, presentational). Count exposed nodes for the budget; ignored ones still cost memory but not reader processing.

Test data. Short placeholder strings understate cost. Use realistic cell content, including links and badges if production has them.

Warm versus cold. The first sort after load is often slower (buffers being built). Measure both, and report the cold number.

Design system notes

Permalink to "Design system notes"

Publish a per-row node cost for each table cell type in the design system — plain text, link, badge, action menu — so product teams can estimate a table’s cost before building it. A row of eight plain cells and a row with a checkbox, two links and a menu button differ by a factor of three.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Why are large tables slow for screen reader users?

Every cell becomes a node in the accessibility tree, and screen readers keep their own copy of the page. Large tables mean large trees and large buffers, and every update must be mirrored and re-processed, which causes pauses sighted users never notice.

How do I count accessibility tree nodes?

Use the Chrome DevTools Protocol method Accessibility.getFullAXTree, from Playwright or Puppeteer, and count the nodes that are not ignored.

Is Lighthouse's DOM size audit enough?

No. It counts DOM elements, not accessibility nodes or update cost, and it cannot see the time screen readers spend processing changes. Use it as a rough signal only.

What row count is too large?

It depends on cell complexity and hardware, which is why you measure. Many teams see screen reader responsiveness degrade in the low thousands of rows and set budgets accordingly.

Permalink to "Related"

← Back to DOM Size Limits and Accessible Performance Tradeoffs