Announcing Live Updates to Screen Readers Permalink to this section
Part of Notification & Activity Feeds, under Real-Time Application Patterns.
A live interface that updates silently is invisible to a screen reader user. One that announces every Server-Sent Event is worse: a busy feed turns speech output into an unstoppable stream that drowns out whatever the user was actually doing. This guide sits between those failures, turning SSE events into a small number of meaningful announcements using ARIA live regions, a priority policy and throttling.
Symptom & Developer Intent Permalink to this section
- Screen reader users report that new notifications “never happen” — the badge changes but nothing is spoken.
- On a busy activity feed, the screen reader reads continuously, interrupting the user mid-sentence and making the page unusable.
- Every reconnect replays and re-announces items that were already heard.
- An error toast steals keyboard focus, and the user loses their place in a form.
- The same text is announced twice because two live regions contain it.
The intent is that a user of VoiceOver, NVDA or JAWS hears important new items promptly, hears routine activity as a periodic summary, is never interrupted for low-priority updates, and never loses focus.
Root Cause Analysis Permalink to this section
Screen readers learn about DOM changes through the accessibility tree. Content appearing somewhere on the page is not announced unless it sits inside a live region — an element with aria-live (or an implicit live role such as status or alert). The assistive technology then decides when to speak it based on the politeness setting: polite waits for the user to be idle, assertive interrupts.
Three implementation details produce most of the failures:
- The region must exist before the change. Creating an element with
aria-liveand filling it in the same tick is unreliable across screen readers; the region has to be present in the DOM first, then its content changes. - Every change is announced. A region that receives twenty text updates in two seconds generates twenty announcements, queued or interrupting each other depending on politeness.
- Replay looks like news. The stream’s reconnect replay writes old items into the DOM exactly as new ones arrive, and the live region cannot tell them apart.
Step-by-Step Resolution Permalink to this section
Step 1 — Render two persistent, visually hidden live regions Permalink to this section
<!-- Present from first render, never removed. -->
<div id="sr-polite" class="visually-hidden" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="sr-assertive" class="visually-hidden" role="alert" aria-live="assertive" aria-atomic="true"></div>
.visually-hidden {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
The visible feed itself should not be a live region. It is a list the user can navigate; announcements go through these dedicated regions so wording and timing stay under control.
Step 2 — Classify events by priority Permalink to this section
// Which events deserve speech, and how urgently.
const PRIORITY = {
mention: 'immediate', // someone addressed the user directly
export_ready: 'immediate', // the user is waiting for this
error: 'assertive', // failures the user must act on
comment: 'summary', // routine activity: batch it
reaction: 'silent', // visible, never spoken
};
Keep assertive for genuine problems. A new message is important but not an emergency; it should wait for a pause in speech rather than cut off the user’s screen reader mid-word.
Step 3 — Build an announcer that throttles and summarises Permalink to this section
// announcer.js
export function createAnnouncer({ politeEl, assertiveEl, summaryMs = 10000, minGapMs = 1500 }) {
let pending = 0;
let lastSpoken = 0;
let timer = null;
function speak(el, text) {
// Clear then set on the next frame so identical consecutive text is still announced.
el.textContent = '';
requestAnimationFrame(() => { el.textContent = text; });
lastSpoken = Date.now();
}
return {
immediate(text) {
const wait = Math.max(0, minGapMs - (Date.now() - lastSpoken));
setTimeout(() => speak(politeEl, text), wait); // never two in the same breath
},
assertive(text) { speak(assertiveEl, text); },
summary() {
pending += 1;
if (timer) return;
timer = setTimeout(() => {
speak(politeEl, `${pending} new ${pending === 1 ? 'update' : 'updates'} in activity`);
pending = 0;
timer = null;
}, summaryMs);
},
};
}
The summary text matters as much as its timing. “7 new updates in activity” is short, starts with the number that tells the user whether to look, and names where to look. Avoid timestamps, usernames lists and emoji in announcements — each adds seconds of speech that the user cannot skip. If the product has a keyboard shortcut to jump to the activity panel, mention it in the first summary of a session only; repeating it every ten seconds turns guidance into noise.
In a React application the announcer belongs in a context provider at the app root, created once, so that any component — the bell, a job progress card, a chat panel — can request an announcement without owning a live region:
const AnnouncerContext = createContext(null);
export function AnnouncerProvider({ children }) {
const polite = useRef(null);
const assertive = useRef(null);
const announcer = useMemo(() => lazyAnnouncer(() => ({
politeEl: polite.current, assertiveEl: assertive.current,
})), []);
return (
<AnnouncerContext.Provider value={announcer}>
{children}
<div ref={polite} className="visually-hidden" role="status" aria-live="polite" aria-atomic="true" />
<div ref={assertive} className="visually-hidden" role="alert" aria-live="assertive" aria-atomic="true" />
</AnnouncerContext.Provider>
);
}
export const useAnnouncer = () => useContext(AnnouncerContext);
The regions render with the provider, so they exist before any stream connects, and there is exactly one pair per page regardless of how many components announce.
Step 4 — Connect the stream without announcing replays Permalink to this section
const announcer = createAnnouncer({
politeEl: document.getElementById('sr-polite'),
assertiveEl: document.getElementById('sr-assertive'),
});
const pageLoadedAt = Date.now();
const heard = new Set();
es.addEventListener('notification', (e) => {
const n = JSON.parse(e.data);
renderIntoFeed(n); // the visible list, not live
if (heard.has(n.id) || Date.parse(n.ts) < pageLoadedAt) return; // replay or initial load
heard.add(n.id);
switch (PRIORITY[n.kind] ?? 'summary') {
case 'immediate': announcer.immediate(describe(n)); break;
case 'assertive': announcer.assertive(describe(n)); break;
case 'summary': announcer.summary(); break;
default: break; // silent
}
});
function describe(n) {
// Short, front-loaded, no emoji or decorative symbols.
return n.kind === 'mention' ? `${n.actor} mentioned you in ${n.target}` : `${n.target} is ready`;
}
Step 5 — Announce connection problems once, not on every retry Permalink to this section
let announcedOffline = false;
es.addEventListener('error', () => {
if (!announcedOffline) { announcer.immediate('Live updates paused, reconnecting'); announcedOffline = true; }
});
es.addEventListener('open', () => {
if (announcedOffline) { announcer.immediate('Live updates resumed'); announcedOffline = false; }
});
EventSource fires error on every failed attempt, which during an outage can be every few seconds. One announcement when the connection is lost and one when it is restored is enough; the visual indicator from showing connection status in the UI carries the detail.
Step 6 — Never move focus for an update Permalink to this section
Toasts and new items must not call focus(). If an update needs action, make the action reachable — a “View” link in the toast, or a keyboard shortcut announced once — and leave focus where the user put it.
Validation & Monitoring Permalink to this section
Test with real screen readers; automated tools cannot judge whether announcements are appropriate.
# Drive a busy feed to test throttling: 30 routine events and one mention over 20 s.
for i in $(seq 1 30); do
curl -s -X POST -H 'Content-Type: application/json' \
-d "{\"kind\":\"comment\",\"target\":\"doc $i\"}" https://app.example.com/dev/notify; sleep 0.6
done
curl -s -X POST -d '{"kind":"mention","actor":"Ana","target":"the roadmap"}' \
-H 'Content-Type: application/json' https://app.example.com/dev/notify
Axe and Lighthouse will confirm that the live regions exist and are labelled correctly. Add an automated check that the regions are present in the initial HTML, since a refactor that renders them lazily silently breaks every announcement.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should the notification list use aria-live?
No. A live list announces every insertion, including replays and bulk loads, with no control over wording or rate. Keep the list navigable and send curated text to a dedicated hidden region.
When is aria-live="assertive" appropriate?
For errors and time-critical states the user must act on, such as a failed save or an expiring session. New messages, mentions and completed jobs are important but should be polite, so they wait for a pause rather than interrupt.
Why clear the region before setting new text?
Several screen readers do not announce a region whose text did not change. Clearing it and setting the text on the next frame makes repeated announcements, such as two identical summaries, reliable.
Can users control how chatty announcements are?
They should be able to. A simple setting — all, important only, off — mapped onto the priority table gives screen reader users the same control over interruption that sighted users get from notification settings.