How to test aria live regions
Capture the accessibility tree before the change and a MutationObserver log during it. On the test page below, two regions were in the tree before the click and four after, so two of the four were built at the moment they were needed.
Why check this
Run this on every screen that reports a result without moving focus: save confirmations, validation summaries, result counts, upload progress, toasts. It belongs in regression after any change to the component that renders them.
The failure it prevents is invisible in review. The message is rendered together with its region wrapper, so the text is on screen and the tree afterwards looks correct. Nothing was announced, because when the text arrived no live region was watching.
What this procedure decides, and what it cannot
No screen reader runs on the machine that produced the output below, so nothing here claims what was spoken. It decides the four conditions an announcement needs: the region was in the tree before the change, aria-live resolved to polite or assertive, aria-atomic and aria-relevant resolved to what the author meant, and the change was text arriving inside the region. A page failing any of these is silent everywhere. A page passing all of them still needs one listening pass.
Prerequisites
- Chrome 120 or later and Node 22, with
npm i puppeteer-corefor the driver. - WAI-ARIA 1.2 live region attributes and the Authoring Practices note on live regions, which requires the region to exist before the content changes.
- A page with one working region and three broken ones, served locally. Save it beside the
server.mjsfrom How to check accessibility tree, runnode server.mjs 8756, and stop that PID afterwards.
<!-- live.html -->
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Upload queue</title></head>
<body>
<h1>Upload queue</h1>
<button id="run">Upload</button>
<div id="a-region" aria-live="polite"></div>
<div id="b-host"></div>
<div id="c-host"><div id="c-region" aria-live="polite">Ready</div></div>
<div id="d-region" aria-live="assertive" style="display:none"></div>
<script>
document.getElementById('run').addEventListener('click', () => {
// A: text written into a region that was in the DOM before the click.
document.getElementById('a-region').textContent = '3 files uploaded';
// B: the region is created and filled in the same task.
const b = document.createElement('div');
b.setAttribute('aria-live', 'polite');
b.id = 'b-region';
b.textContent = '3 files uploaded';
document.getElementById('b-host').appendChild(b);
// C: the region element itself is thrown away and rebuilt.
document.getElementById('c-host').innerHTML =
'<div id="c-region" aria-live="polite">3 files uploaded</div>';
// D: a hidden region is filled, then revealed.
const d = document.getElementById('d-region');
d.textContent = '3 files uploaded';
d.style.display = 'block';
});
</script>
</body>
</html>
- A second page, for step 3, with roles that imply a live region.
<!-- roles.html -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>live defaults</title></head><body>
<div id="explicit" aria-live="polite">a</div>
<div id="status" role="status">b</div>
<div id="alert" role="alert">c</div>
<div id="log" role="log">d</div>
<div id="tuned" aria-live="polite" aria-atomic="true" aria-relevant="removals">e</div>
<output id="output">f</output>
<div id="hidden-region" role="status" aria-hidden="true">g</div>
<div id="off" aria-live="off">h</div>
</body></html>
- The same page, fixed, for step 4.
<!-- live-fixed.html -->
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Upload queue, fixed</title>
<style>.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}</style></head>
<body>
<h1>Upload queue</h1>
<button id="run">Upload</button>
<div id="a-region" aria-live="polite"></div>
<div id="b-region" aria-live="polite"></div>
<div id="c-host"><div id="c-region" aria-live="polite">Ready</div></div>
<div id="d-region" aria-live="assertive" class="sr"></div>
<script>
document.getElementById('run').addEventListener('click', () => {
for (const id of ['a-region', 'b-region', 'c-region', 'd-region']) {
document.getElementById(id).textContent = '3 files uploaded';
}
});
</script>
</body>
</html>
- Two scripts: one reads the tree on both sides of a click, one watches the mutations in between.
// live-tree.mjs
import { launch } from 'puppeteer-core';
const [url, click] = process.argv.slice(2);
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto(url, { waitUntil: 'networkidle2' });
async function dump(label) {
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const live = nodes.filter((n) => (n.properties ?? []).some((p) => p.name === 'live' && p.value.value !== 'off'));
console.log(`${label}: ${live.length} live region(s) in the tree`);
for (const n of live) {
const p = Object.fromEntries((n.properties ?? []).map((x) => [x.name, x.value.value]));
const { node } = await cdp.send('DOM.describeNode', { backendNodeId: n.backendDOMNodeId });
const attrs = node.attributes ?? [];
const id = attrs[attrs.indexOf('id') + 1] ?? '(no id)';
const text = nodes.filter((c) => n.childIds.includes(c.nodeId)).map((c) => c.name?.value ?? '').join('');
console.log(` #${id}`.padEnd(13) +
`live=${p.live} atomic=${p.atomic} relevant=${JSON.stringify(p.relevant ?? '')}`.padEnd(52) +
`text=${JSON.stringify(text)}`);
}
}
if (!click) { await dump('at load'); }
else {
await dump('before the click');
await page.click(click);
await new Promise((r) => setTimeout(r, 500));
await dump('after the click');
}
await browser.close();
// live-watch.mjs
import { launch } from 'puppeteer-core';
const [url, click] = process.argv.slice(2);
const LIVE = '[aria-live], [role=status], [role=alert], [role=log], [role=timer], [role=marquee]';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
await page.evaluate((sel) => {
const label = (n) => (n.nodeType === 3 ? `#text ${JSON.stringify(n.data.slice(0, 22))}` : `<${n.nodeName.toLowerCase()}${n.id ? '#' + n.id : ''}>`);
const region = (n) => (n.nodeType === 3 ? n.parentElement : n)?.closest(sel) ?? null;
// Every live region that exists before anything changes. A region missing from
// this set when its text arrives was built after the fact.
window.__atLoad = new Set([...document.querySelectorAll(sel)]);
window.__log = [];
const verdict = (n) => {
const r = region(n);
if (!r) return 'not inside a live region';
const self = r === n ? 'the region itself: ' : '';
return self + (window.__atLoad.has(r) ? `#${r.id} existed at load` : `#${r.id} BUILT AFTER LOAD`);
};
new MutationObserver((records) => {
for (const r of records) {
if (r.type === 'characterData') window.__log.push('text changed '.padEnd(12) + label(r.target).padEnd(34) + verdict(r.target));
if (r.type === 'attributes') window.__log.push('attribute'.padEnd(12) + `${label(r.target)} ${r.attributeName}`.padEnd(34) + verdict(r.target));
for (const n of r.addedNodes) window.__log.push('added'.padEnd(12) + label(n).padEnd(34) + verdict(n));
for (const n of r.removedNodes) window.__log.push('removed'.padEnd(12) + label(n).padEnd(34) + (n.nodeType === 1 && window.__atLoad.has(n) ? `the region itself: #${n.id} existed at load` : verdict(r.target)));
}
}).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['aria-live', 'style', 'hidden', 'aria-hidden'] });
}, LIVE);
await page.click(click);
await new Promise((r) => setTimeout(r, 500));
for (const line of await page.evaluate(() => window.__log)) console.log(line);
await browser.close();
- Every figure below is one capture on one machine, Chrome 152.0.7977.76, 2026-09-11.
Steps
- Step 1.
Count the live regions before the change and after it.
node live-tree.mjs http://127.0.0.1:8756/live.html "#run"before the click: 2 live region(s) in the tree #a-region live=polite atomic=false relevant="additions text" text="" #c-region live=polite atomic=false relevant="additions text" text="Ready" after the click: 4 live region(s) in the tree #a-region live=polite atomic=false relevant="additions text" text="3 files uploaded" #d-region live=assertive atomic=false relevant="additions text"text="3 files uploaded" #b-region live=polite atomic=false relevant="additions text" text="3 files uploaded" #c-region live=polite atomic=false relevant="additions text" text="3 files uploaded"Read the two counts, not the four final lines. Afterwards the four regions look identical. Before the click there are two:
#b-regiondid not exist, and#d-regionwasdisplay:none, which keeps an element out of the tree. Two of the four had nothing watching when their text arrived. - Step 2.
Watch the mutations, and ask of each whether its region was already there.
node live-watch.mjs http://127.0.0.1:8756/live.html "#run"added #text "3 files uploaded" #a-region existed at load added <div#b-region> the region itself: #b-region BUILT AFTER LOAD added <div#c-region> the region itself: #c-region BUILT AFTER LOAD removed <div#c-region> the region itself: #c-region existed at load added #text "3 files uploaded" #d-region existed at load attribute <div#d-region> style the region itself: #d-region existed at loadLine 1 is the shape a working announcement has: text arriving inside a region that was already there. Line 2 is a region delivered with its message inside it. Lines 3 and 4 are a replacement: the node that carried
aria-liveis not the node holding the text, although both have idc-region. Lines 5 and 6 show#d-regionfilled and then revealed, which is backwards. - Step 3.
Read what the live attributes resolve to, including ones nobody wrote.
node live-tree.mjs http://127.0.0.1:8756/roles.htmlat load: 6 live region(s) in the tree #explicit live=polite atomic=false relevant="additions text" text="a" #status live=polite atomic=true relevant="additions text" text="b" #alert live=assertive atomic=true relevant="additions text"text="c" #log live=polite atomic=false relevant="additions text" text="d" #tuned live=polite atomic=true relevant="removals" text="e" #output live=polite atomic=true relevant="additions text" text="f"Six of the eight are live regions:
aria-hidden="true"removed#hidden-regionfrom the tree, andaria-live="off"is not one.role="status"andaria-live="polite"differ onatomic: true for the role, false for the bare attribute.<output>behaves asrole="status".#tunedshows the cost ofaria-relevant: it gainedremovalsand lost the defaultadditions text. - Step 4.
Fix the page and run the same two readings.
node live-tree.mjs http://127.0.0.1:8756/live-fixed.html "#run"before the click: 4 live region(s) in the tree #a-region live=polite atomic=false relevant="additions text" text="" #b-region live=polite atomic=false relevant="additions text" text="" #d-region live=assertive atomic=false relevant="additions text"text="" #c-region live=polite atomic=false relevant="additions text" text="Ready" after the click: 4 live region(s) in the tree #a-region live=polite atomic=false relevant="additions text" text="3 files uploaded" #b-region live=polite atomic=false relevant="additions text" text="3 files uploaded" #d-region live=assertive atomic=false relevant="additions text"text="3 files uploaded" #c-region live=polite atomic=false relevant="additions text" text="3 files uploaded"Four before and four after is what to look for.
#d-regionis now hidden with a clip rectangle instead ofdisplay:none, and a clipped element stays in the tree. - Step 5.
Confirm the mutation log for the fixed page.
node live-watch.mjs http://127.0.0.1:8756/live-fixed.html "#run"added #text "3 files uploaded" #a-region existed at load added #text "3 files uploaded" #b-region existed at load added #text "3 files uploaded" #c-region existed at load removed #text "Ready" the region itself: #c-region existed at load added #text "3 files uploaded" #d-region existed at loadEvery line names a region that existed at load, and every added node is text.
#c-regionshows an addition and a removal becausetextContentreplaces the child text while the region element survives.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| More live regions after the change than before | A region was created along with its message | Render the empty region with the rest of the view, then write text into it. |
| BUILT AFTER LOAD on an added element | The region and its content arrived in one mutation | Same fix. The count in the tree afterwards will not show this. |
| A removed and an added line for the same id | The region element was replaced, not updated | Write to textContent on the existing node instead of rebuilding the parent's innerHTML. |
| A region in the DOM and absent from the tree | display:none, hidden, or aria-hidden="true" on it or an ancestor | Use a clip rectangle to hide it visually, and reveal before writing, not after. |
| atomic=true where you expected false | The element has role="status", role="alert" or is an output | Decide which you want. atomic=true asks for the whole region on every change, not only the part that changed. |
| relevant="removals" | Writing aria-relevant replaced the default, it did not extend it | List every value you need, including additions and text. |
Common mistakes
What to check next
- How to test aria attributes: whether
aria-livereached the tree with the value you wrote. - How to check accessibility tree: where these captures come from, and why hidden elements are missing.
- How to check aria labels: naming the region and the control that triggers it.
- How to check if a form is accessible: validation summaries, the commonest live region.
- How to check focus order: the alternative to announcing, after a route change.
FAQ
What is aria live?
An attribute marking part of the page whose changes are reported without the user going to look. polite waits for a pause, assertive interrupts. Chrome exposes the resolved value as a live property.
How do I test aria live without a screen reader?
Check the four conditions named under Why check this. Steps 1 to 3 produce them all, and what was spoken still needs a listening pass.
Why is my aria live region not announced?
The commonest cause is the region being created together with its message, which step 2 reports as BUILT AFTER LOAD. Next is a region replaced rather than updated. Third is a region hidden with display:none when the text is written.
Should I use aria-live="assertive" or role="alert"?
role="alert" resolves to live=assertive with atomic=true, as step 3 shows, so it re-reads the whole region. Reserve either for something the user must act on now. Two assertive messages arriving together interrupt each other.
Verified
Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
advanced11 minpublished updated Maks Verny