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.

What aria-busy is and is not Comparison of what aria-busy does against the jobs it is frequently but wrongly expected to do. What aria-busy is and is not✓ aria-busy doesSuppress live-region announcements in itssubtreeStop a half-built region being describedApply to the container, not to the page✗ aria-busy does notTell the user anythingReplace a status messageHide content from the accessibility tree
It is a mute button, not a narrator.

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.

The aria-busy lifecycle Timeline of aria-busy across one load: set true before the first write, held true through the batched writes, cleared one frame before the settle message. The aria-busy lifecycleBefore writesaria-busy="true"During writesheld true, silentWrites donearia-busy="false"+1 framesettle message writtentime across one load →
Clearing busy and announcing in the same frame is why VoiceOver users hear nothing.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
aria-busy support and behaviour Matrix of NVDA, JAWS and VoiceOver behaviour when aria-busy is set, held and cleared on a region receiving batched writes. aria-busy support and behaviourNVDA + FirefoxJAWS + ChromeVoiceOver + SafariWhile truelive updates suppressedlive updates suppressedlive updates suppressedOn clearingresumes immediatelyresumes immediatelyresumes after a short delayMessage in the sameframeannouncedusually announcedfrequently dropped
All three honour it; only one is fussy about the moment it clears.

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.

Permalink to "Related"

Back to Progressive Loading & Skeleton States