Setting a DOM Node Budget in CI
Permalink to "Setting a DOM Node Budget in CI"A DOM node budget in CI is an automated assertion that fails the build when a route renders more elements than an agreed ceiling, catching node bloat before it reaches production. The single failure it prevents is silent accumulation: wrapper <div>s and un-virtualized lists creep in commit by commit until a route’s tree grows large enough to stall a screen reader on startup and drop live-region announcements. This guide writes the Playwright or Puppeteer assertion, explains why oversized trees punish assistive technology, and covers picking a threshold and reporting. It is the CI enforcement mechanism for the thresholds analysed in DOM size limits and accessible performance tradeoffs.
Spec reference
Permalink to "Spec reference"There is no WCAG success criterion that names a node count directly; the budget is a proxy control for outcomes that several criteria require. A DOM tree in the tens of thousands of nodes forces assistive technology to spend seconds building its accessibility tree, lag on focus moves, and miss mutations — degrading 4.1.3 Status Messages (Level AA) when live regions fire before the AT is ready, 2.4.3 Focus Order (Level A) when focus parsing stalls, and 2.2.1 Timing Adjustable (Level A) when the interface becomes too slow to operate within a reasonable time. Lighthouse surfaces excessive DOM size as a diagnostic at roughly 1,500 nodes and treats depth beyond 32 levels or 60-plus children per parent as problematic; those figures are the practical anchors teams build budgets around.
The measurement itself is trivial and stable: document.querySelectorAll('*').length returns the count of element nodes in the live document, evaluated after the page has settled.
When to use — and when not to
Permalink to "When to use — and when not to"Add a node budget to any route that renders data-heavy content — grids, feeds, dashboards, search results — where the count can grow with data volume or with accreted markup. These are exactly the routes where a virtualization regression (a list that stops virtualizing and renders all 12,000 rows) does the most damage, and where a hard ceiling pays for itself.
Do not scatter tight budgets across trivial static pages; a marketing page with 200 nodes gains nothing from a gate and will only generate noise. And do not set the budget so low that it fails on legitimate content and trains the team to bump the number reflexively — a budget that is edited upward on every red build enforces nothing. Reserve the gate for routes where node count is a genuine risk, and set the ceiling with real headroom.
Annotated code example
Permalink to "Annotated code example"Playwright assertion
Permalink to "Playwright assertion"// SC 4.1.3 / 2.4.3 / 2.2.1: an oversized DOM delays the accessibility tree,
// stalls focus parsing, and causes live regions to fire before AT is ready.
import { test, expect } from '@playwright/test';
// Per-route ceilings: set just above today's count, ratchet down over time
const BUDGET = {
'/dashboard': 1400,
'/transactions': 1500,
'/reports': 1200,
};
for (const [route, max] of Object.entries(BUDGET)) {
test(`DOM node budget: ${route}`, async ({ page }) => {
await page.goto(route);
// Measure the SETTLED, post-hydration DOM, not the pre-JS payload
await page.waitForLoadState('networkidle');
const nodeCount = await page.evaluate(
() => document.querySelectorAll('*').length
);
// Report the number on every run so trends are visible in CI logs
console.log(`${route}: ${nodeCount} nodes (budget ${max})`);
expect(nodeCount, `${route} exceeded DOM node budget`).toBeLessThanOrEqual(max);
});
}
Puppeteer equivalent
Permalink to "Puppeteer equivalent"// Same measurement in Puppeteer for teams not on Playwright
const puppeteer = require('puppeteer');
async function checkBudget(route, max) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(`http://localhost:3000${route}`, { waitUntil: 'networkidle0' });
const nodeCount = await page.evaluate(() => document.querySelectorAll('*').length);
await browser.close();
if (nodeCount > max) {
// Non-zero exit fails the CI job (SC 2.2.1: guards AT-timing regressions)
throw new Error(`${route}: ${nodeCount} nodes exceeds budget of ${max}`);
}
console.log(`${route}: ${nodeCount}/${max} nodes — within budget`);
}
Reporting the worst offenders
Permalink to "Reporting the worst offenders"// When a route fails, surface WHERE the nodes are so the fix is obvious
const breakdown = await page.evaluate(() => {
const counts = {};
document.querySelectorAll('*').forEach(el => {
counts[el.tagName] = (counts[el.tagName] || 0) + 1;
});
// Top 5 tags by count — usually reveals a wrapper-div explosion
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
});
console.log('Top node types:', breakdown);
Measuring after networkidle captures the true count for a client-rendered or hydrated app; counting before JavaScript runs undercounts and lets regressions through. The tag breakdown turns a red build into an actionable one — a sudden spike in DIV points at wrapper bloat, a spike in TR at a list that stopped virtualizing.
Node count reference
Permalink to "Node count reference"| Count (per route) | AT impact | Action |
|---|---|---|
| < 800 | None | No budget needed |
| 800 – 1,400 | Minor parse lag on some AT | Set a budget near the top of this band |
| 1,400 – 3,000 | Startup delay, missed mutations | Virtualize lists; flatten wrappers |
| > 3,000 | Screen reader timeouts, focus lag | Fail the build; architecture review |
Integration context
Permalink to "Integration context"This gate is the enforcement half of DOM size limits and accessible performance tradeoffs, which explains the thresholds and the profiling behind them. The most common way to bring a failing route back under budget is to virtualize its long lists — see accessible virtualized list patterns for doing that without breaking the accessibility tree, and the Vue and react-window guides for the framework specifics. Run the budget check in the same CI stage as your axe-core accessibility assertions so all AT-affecting regressions gate together.
Gotchas
Permalink to "Gotchas"- Counting before hydration. A
networkidlewait (or an explicit ready signal) is essential; counting the pre-JavaScript DOM lets a client-side render blow past the budget undetected. - A budget that only ever goes up. If the number is raised on every failure, the gate enforces nothing. Treat an increase as a code-review decision with justification, and ratchet the ceiling down after optimisation work.
- Ignoring shadow DOM and iframes.
querySelectorAll('*')does not pierce shadow roots or iframe documents. If your heavy content lives in a web component or embedded frame, count inside those trees too or the budget misses the real cost.
FAQ
Permalink to "FAQ"What DOM node count should I set the budget to?
Base it on each route’s current count plus modest headroom rather than a universal number. Lighthouse flags trees above about 1,500 nodes, so many teams start with a per-route ceiling near their present count, often in the 1,200 to 1,500 range, then ratchet it down as they optimise. The budget is a regression guard: its job is to stop the count creeping up, so set it just above today’s value, not at an aspirational low.
Why do large DOM trees hurt screen reader users specifically?
Assistive technology builds its own accessibility tree from the DOM, so a very large tree means a slow startup as the screen reader parses tens of thousands of nodes, multi-second lag when moving focus, and missed live-region mutations because the AT is still processing an earlier change. Keeping the node count bounded keeps the accessibility tree fast to build and keeps announcements timely.
Should the DOM budget test run with JavaScript enabled?
Yes. Client-rendered and hydrated apps only reach their true node count after JavaScript runs, so measure the settled, post-hydration DOM. Wait for the network to be idle or for a known ready signal before counting. It is also worth measuring the pre-JavaScript HTML separately, because a huge server-rendered payload delays first paint even before hydration adds more nodes.
Related
Permalink to "Related"- DOM size limits and accessible performance tradeoffs — the thresholds, profiling, and AT-impact analysis this gate enforces
- Accessible virtualized list patterns — the usual fix for a route over budget: virtualize its long lists
- Accessible virtual scrolling in Vue with vue-virtual-scroller — framework-specific virtualization to bring node counts down
← Back to DOM Size Limits and Accessible Performance Tradeoffs