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

<!-- 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>
<!-- 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>
<!-- 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>
// 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();

Steps

  1. 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-region did not exist, and #d-region was display:none, which keeps an element out of the tree. Two of the four had nothing watching when their text arrived.

  2. 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 load

    Line 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-live is not the node holding the text, although both have id c-region. Lines 5 and 6 show #d-region filled and then revealed, which is backwards.

  3. Step 3.

    Read what the live attributes resolve to, including ones nobody wrote.

    node live-tree.mjs http://127.0.0.1:8756/roles.html
    
    at 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-region from the tree, and aria-live="off" is not one. role="status" and aria-live="polite" differ on atomic: true for the role, false for the bare attribute. <output> behaves as role="status". #tuned shows the cost of aria-relevant: it gained removals and lost the default additions text.

  4. 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-region is now hidden with a clip rectangle instead of display:none, and a clipped element stays in the tree.

  5. 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 load

    Every line names a region that existed at load, and every added node is text. #c-region shows an addition and a removal because textContent replaces 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

Sign: The live region is checked after the update and looks correct.Cause: Step 1 gives four correct regions after the click and two before it. The end state is the same whether the region waited in the page or arrived with its message. Only the before reading, or the mutation log in step 2, separates them, and the broken ones are the two that a screenshot and a tree dump both pass.
Sign: role="status" and aria-live="polite" are treated as the same markup.Cause: Step 3 shows them differing on aria-atomic: the role resolves to atomic=true, the bare attribute to atomic=false. WAI-ARIA defines atomic=true as presenting the whole region on every change rather than only the part that changed, so the two markups hand assistive technology different instructions. No screen reader was run here, and the difference is in the capture.
Sign: A region is hidden with display:none and revealed when a message arrives.Cause: Chrome leaves such an element out of the accessibility tree, so at the moment the text is written the region does not exist for the platform. Step 1 counts #d-region only after the click. The sequence in step 2 makes it worse: the text was added first and the style changed afterwards.
Sign: aria-relevant is written to add a value.Cause: It replaces the value. #tuned in step 3 asked for removals and ended up with relevant="removals" alone, instead of the default additions text. Added text is now outside what that region reports, and every other reading on the page still looks right.

What to check next

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.

advanced11 minpublished updated Maks Verny