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.getFullAXTreereturns 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.
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.
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.
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.
Related
Permalink to "Related"- Setting a DOM node budget in CI — enforcing the budget
- Pagination versus virtualization — what to do when over budget
- content-visibility and screen readers — a rendering shortcut and its limits
← Back to DOM Size Limits and Accessible Performance Tradeoffs