Live Regions for Order Status, Announced Once and Not Twice
A live region for order status fails in both directions. It either interrupts on every poll or never speaks at all, and the fix starts before the first update.
Priya Raghunathan · @praghunathan
Frontend and accessibility
- Topic
- FE
- Section
- Frontend & Accessibility
- Published
- 17 AUG 2026
- Reading time
- 7 MIN
- Words
- 1804W
A status badge that changes colour is easy to miss, so somebody adds a live region to announce it. A month later a customer emails to say the tracking page talks over itself and cannot be read. That is the ordinary lifecycle of this feature. The announcement is the right idea, applied at the wrong moment with the wrong attributes, and the result is worse than no announcement at all.
The opposite failure gets reported even less, because nobody knows it happened. The region never speaks. The markup looks correct, the text on screen changes, and a screen reader user simply never learns that the parcel moved. Both outcomes come from the same set of details, and those details are worth getting right because they are not obvious from reading the markup.
Three attributes decide what gets spoken, and when
aria-live is the switch. With polite, assistive technology queues the
announcement until the user pauses, so it arrives after the current sentence instead of cutting into
it. With assertive, the announcement interrupts whatever is being read. Assertive is
not a stronger version of polite; it is a different decision about whose turn it is to speak. On a
page a customer is reading with their ears, taking that turn away is expensive.
aria-atomic decides how much gets re-read. On a region with
aria-atomic="true", the whole region is announced again after any change inside it. On
a region without it, which is the default, only the part that changed is announced. For a single
short status sentence the two behave almost the same. They diverge the moment the region holds more
than one sentence, which is also the moment you start hearing the same information twice.
Neither attribute exists without a third condition, and this is the part that surprises people. A live region has to be in the DOM before its content changes. A region inserted at the same moment as its text, in one operation, is frequently not announced at all. The assistive technology watches a node that already exists and reports changes inside it; a node that appears complete does not present itself as a change. If you build the region and fill it in the same function, whether it speaks depends on the browser and the screen reader, which is another way of saying it is not reliable.
The practical rule that follows: put the region in the markup the server sends, leave the element node in place, and change only its text. Never render a status message by assigning markup into a fresh container and hoping.
An AJAX poll turns a polite region into a metronome
The first failure comes from updating on every poll rather than on every change. A tracking page that refetches status every thirty seconds and writes the result into a polite region will announce "in transit" eight times in four minutes if it writes unconditionally. Polite softens that. It does not make it tolerable. The user's screen reader is now a device that repeats a sentence every half minute while they try to read the rest of the page.
There is a nastier version of the same bug. If the update replaces the region element itself, rather than its text, the live region registration goes with the old node. After the first poll the page keeps updating on screen and the announcements stop. Nothing throws an error, so the regression ships quietly. A component that re-renders its own wrapper is the usual cause, and it is easy to do by accident when the status markup is assembled from a template string.
Both failures have the same fix: compare before you write.
let previousStatus = null;
function updateStatus( next ) {
const region = document.getElementById( 'tracking-status-live' );
// Structural check as well as a value check. If the region is gone,
// an earlier update replaced it and nothing will be announced again.
if ( ! region || next.status === previousStatus ) {
return;
}
previousStatus = next.status;
// Write text into the text node that is already in the region.
region.textContent = `${ next.label }. Last updated ${ next.updatedAt }.`;
}
The previousStatus comparison is the whole mechanism. An unchanged poll returns
before touching the DOM, so an idle order produces one announcement for the life of the page instead
of one per interval. The structural check catches the second failure mode, and it is worth adding a
log line to it during development, because a region that has been replaced announces nothing and you
will not notice from the screen.
Markup for a timeline that has a shape
The timeline itself needs structure for its own sake. A list of steps with no headings has no navigable shape, so a screen reader user moving by heading lands on the page title and then nothing. Give the timeline a heading, mark it up as an ordered list because the sequence is the meaning, and label each step.
<section class="tracking-timeline" aria-labelledby="timeline-heading">
<h3 id="timeline-heading" class="visually-hidden">Delivery timeline</h3>
<p class="tracking-summary">
<span id="tracking-status-live" role="status" aria-atomic="true">
Order placed. Awaiting carrier collection.
</span>
</p>
<ol class="tracking-steps">
<li class="step is-complete">
<span class="step-icon" aria-hidden="true">✓</span>
<span class="step-label">Order placed</span>
<span class="step-time">12 Aug, 09:14</span>
</li>
<li class="step is-current">
<span class="step-icon" aria-hidden="true">●</span>
<span class="step-label">In transit</span>
<span class="step-time">12 Aug, 17:02</span>
</li>
<li class="step">
<span class="step-icon" aria-hidden="true">○</span>
<span class="step-label">Out for delivery</span>
<span class="step-time">Expected 14 Aug</span>
</li>
</ol>
</section>
Two things about that fragment are deliberate. The heading is
visually-hidden rather than absent, so the timeline appears in the heading list without
adding a visible label the designer did not ask for. And the region carries the status text as its
initial content, in the HTML the server sends, which is what makes the later updates announceable at
all.
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset( 50% );
white-space: nowrap;
border: 0;
}
The icons are marked aria-hidden="true", so a screen reader does not read out
punctuation for a check mark next to a step label that already says "Order placed". That is also how
the timeline avoids communicating by colour alone. Each step has an icon and a text label, and the
current step is distinguished by both. A status conveyed only through a green background disappears
for anyone who cannot separate green from grey, which is a larger group than most teams assume.
Choosing between role="status" and role="alert"
role="status" is a polite live region with aria-atomic implied. Use it
for the order status summary. It is the same behaviour as writing
aria-live="polite" aria-atomic="true" by hand, and the role exists so that the intent
is legible to whoever reads the template next.
role="alert" is assertive. Reach for it when something has gone wrong in a way the
user has to act on, and be strict about that test. On a tracking page, almost nothing qualifies. A
courier scan that is running late is information, not an alert. A failed payment on the order is
closer, and a session timeout that discarded their input is closer still, but those belong to the
page the user is actually working on, not to a status line that refreshed in the background while
they read something else.
The argument against assertive announcements has a practical edge. An assertive message
interrupts a sentence in progress, and the user cannot rewind to the start of it. So the cost of
being wrong is not mild annoyance, it is lost context. When I audit a page and find
role="alert" on a polling status region, I treat it as a defect and change it to
role="status" before looking at anything else on the page.
| Pattern | Announcement behaviour | When to use it |
|---|---|---|
role="status" | Queued until the user pauses, whole region re-read | Status summary that changes occasionally |
aria-live="polite" | Queued until the user pauses, changed text only | One live sentence with no extra content |
aria-live="assertive" | Interrupts current speech | Something the user must act on now |
role="alert" | Assertive, announced as soon as it appears | Errors and destructive outcomes |
aria-atomic="true" | Re-reads the entire region on any change | Short regions where partial reads lose meaning |
| Region added with its text | Frequently silent | Never, for content that matters |
| Region with no live attribute | Silent until the user navigates to it | Content the user will read in order anyway |
One more thing about the update function, and it is easy to skip. Write the announcement after the
server has confirmed the change, not when you send the request. Announcing "in transit" while the
request is still in flight means you announce a guess, and a failed request leaves the user with a
spoken status the page does not actually have. The same caution applies to
aria-busy: it is fine for suppressing a region while a container is being replaced, but
setting it and forgetting to clear it leaves the region silent forever.
The limit: over-announcing is the worse outcome
A sighted user with a badge that flashes every thirty seconds can look away. There is no equivalent for a screen reader user, because a live region interrupts on its own schedule and skipping it is not an available action. That asymmetry decides which way to lean. Under-announce and the user finds the status by navigating to it, which costs them a few seconds. Over-announce and the page becomes unusable while they are on it, which costs them the task.
So the honest summary of this pattern is that it is conservative by design. The region is polite, it is atomic, it carries one sentence, and it changes only when the status genuinely differs from the last one you announced. The timeline behind it does the work of showing history, and history does not need to be spoken as it arrives.
What this pattern does not solve is ordering. If two events land in the same poll interval, the region announces the latest one and drops the earlier one. That is usually right for order status, where the newest state is the answer, and wrong if the customer needs to know that a parcel went back a step. If your timeline can move backwards, the summary should say so explicitly rather than leaving the user to infer it from a timestamp.
What to do on Monday
- Put the status region in the server-rendered template with its first message, rather than generating it in the script.
- Change
role="alert"torole="status"anywhere on a polling element and note what you replaced. - Add the previous-value comparison to the update function, and log when the structural check fails so a replaced region is visible in development.
- Give the timeline a heading and an ordered list, then check that heading navigation lands on it.
- Run the page with a screen reader for two minutes without interacting, and count the announcements. More than one on an order that did not change is a bug.
Priya Raghunathan · @praghunathan
Frontend and accessibility
Reviews interfaces for what happens when JavaScript fails, the keyboard is the only input, and the connection is slow. Focused on Core Web Vitals, semantic markup and progressive enhancement.
Continue