Using aria-busy During Progressive Table Loads
Permalink to "Using aria-busy During Progressive Table Loads"aria-busy is the most misunderstood attribute in the loading area. It does exactly one thing — it tells assistive technology to stop announcing live-region changes inside a region while that region is being assembled — and it is routinely expected to do two other things it cannot: tell the user something, and hide content. This page covers its real lifecycle, the VoiceOver timing trap that silently drops the settle message, and the failure that comes from never clearing it.
It is the suppression detail beneath progressive loading and skeleton states, and it pairs with the announcement described in announcing async loading states in data tables.
Spec reference
Permalink to "Spec reference"ARIA defines aria-busy as indicating that an element is being modified, and that assistive technologies may want to wait until the modifications are complete before exposing them to the user. Valid values are true and false; the default is false.
The key phrase is “before exposing them”. It is a suppression hint for changes, not a visibility control and not an announcement. It scopes to the element it is set on and its subtree, which is why the placement matters: a status region inside a busy subtree is suppressed along with everything else.
It is designed for exactly this situation — a container that will be written in several steps, where a partial read would mislead. A table hydrating in batches, a chart whose axes render before its series, a list that appends in chunks.
When to use — and when not to
Permalink to "When to use — and when not to"Use it when the region will be written in more than one step and an intermediate state would be misleading. Batched row insertion is the canonical case: without it, a reader may describe a four-row table that is on its way to four hundred.
Do not use it for a single atomic write. Replacing a tbody in one operation produces one mutation, and there is no partial state to suppress. Adding aria-busy there is harmless but pointless, and it is one more attribute that can be left set.
Do not use it to hide content. That is aria-hidden, and confusing the two produces a region that is announced when it should be silent and reachable when it should not be.
Annotated code example
Permalink to "Annotated code example"// The full lifecycle, including the failure path
async function load(query) {
// 1. Mark busy BEFORE the first write, not after
table.setAttribute('aria-busy', 'true');
status.textContent = 'Loading results'; // status is OUTSIDE the busy region
try {
const rows = await fetchRows(query);
// 2. Write in batches — no announcements are escaping while busy is true
for (const batch of chunk(rows, 50)) {
appendRows(batch);
await nextFrame(); // keep the main thread responsive
}
table.setAttribute('aria-rowcount', String(rows.length));
// 3. Clear busy, THEN wait a frame, THEN announce
table.setAttribute('aria-busy', 'false');
await nextFrame(); // VoiceOver needs this gap
status.textContent = `${rows.length} results loaded`;
} catch (err) {
// 4. The failure path must clear it too, or everything after this is silent
table.setAttribute('aria-busy', 'false');
alertRegion.textContent = 'Could not load results. Retry, or change the filters.';
}
}
The ordering in step three is the whole point of the page. Clearing aria-busy and writing the message in the same frame means VoiceOver observes the message arriving while its own state still says the region is busy, and it discards it. One frame of separation is enough, and it costs nothing on the other readers.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"Note what is not in that matrix: an announcement of the busy state itself. No reader says “busy” or “loading” because aria-busy was set. That is the status message’s job, and it is why the two attributes always ship together.
Integration context
Permalink to "Integration context"aria-busy interacts with every live region inside its subtree, which makes placement the most consequential decision. Put the status region as a sibling of the table, not a child. In component terms: the busy attribute belongs to the data component, and the status region belongs to the view that owns it — or to a shared announcer service, which is the pattern most design systems land on.
In a virtualized list, do not set aria-busy while the window scrolls. Scrolling is not loading, and marking the region busy on every scroll suppresses real announcements — including the position messages from accessible virtualized list patterns.
Gotchas
Permalink to "Gotchas"The status region is inside the busy subtree. The load announces nothing, and it is very hard to diagnose because every attribute looks correct in isolation.
Never cleared on failure. Every subsequent announcement on that region is suppressed for the life of the page. Clear it in finally, not only in the success branch.
Set on the page root. A busy body suppresses every announcement on the page, including alerts.
Toggled per batch. Setting and clearing it around each batch produces a flicker of suppression and a burst of announcements between batches. Set it once, clear it once.
FAQ
Permalink to "FAQ"Should aria-busy be set on the table or on the page?
On the region being written — usually the table or the list container. Setting it on a wrapper that also contains the status region is a common and confusing mistake: the status message is then inside the busy subtree and is suppressed too, so the load announces nothing at all. Keep the status region outside whatever you mark busy.
Does aria-busy hide content from screen readers?
No. Content inside a busy region is still reachable, still readable and still in the accessibility tree. aria-busy only tells assistive technology not to announce live-region changes inside it while the region is being assembled. If you want content hidden, that is aria-hidden, and it is a different attribute with a different effect.
What happens if aria-busy is never cleared?
Every later live-region update inside that region is silently suppressed. This is one of the nastiest bugs in this area because the page looks perfect and the failure is complete: sorts, filters and selections all stop announcing. Clear it in the failure path as well as the success path, and assert on it in a component test.
Related
Permalink to "Related"- Progressive loading & skeleton states — the parent guide and the surrounding state machine
- Announcing streamed rows as data hydrates — the batching this attribute makes safe
- ARIA live regions for dynamic data — what aria-busy suppresses