Accessibility · Semantics and the A11y Tree

Accessibility Tree Invalidation and Event Coalescing

How engines detect that the tree is stale, what work happens before platform events fire, and why a burst of mutations can produce a single announcement.

The accessibility tree is a cache of a computation, and like any cache it must be invalidated. The invalidation model determines what an assistive technology observes: the exact sequence of intermediate states, or a settled snapshot after a burst of changes. Browsers do not deliver every state. They mark objects dirty, recompute them in a batch tied to the rendering pipeline, and emit platform events for the differences that survive coalescing. Which objects exist at all is decided earlier, by the exposure rules described in How a Document Becomes an Accessibility Tree.

This design is forced by the cost of the alternative. A framework that mutates the DOM per animation frame can produce thousands of node insertions and removals per second. Each one is a potential tree change, each tree change is a potential platform event, and each platform event wakes every client process on the system. Delivering them one-for-one would spend more CPU in the accessibility layer than in rendering, and the consumer would fall behind a queue it can never drain.

Three kinds of dirtiness

Engines distinguish structural invalidation from property invalidation from layout invalidation, because the three have different costs and different propagation rules.

A structural change (an insertion, a removal, or a re-parenting of an element that has an accessibility object) marks the nearest ancestor that is not itself dirty and dirties that ancestor’s whole subtree. The subtree mark is coarse because determining which descendant objects actually changed would require walking them, which is the work the mark is meant to defer.

A property change is an attribute that affects role, name, state, or relations. It dirties one object and, for name-affecting attributes, every object whose name references that object through aria-labelledby. The reverse dependency is tracked: each ID referenced by a labelling relation keeps a list of dependents, so a text change inside a referenced element can invalidate the labels that point at it without rescanning the document.

A layout change dirties the geometry of every object whose box moved or resized. Since a reflow can move everything below the change, this is the expensive class, and engines typically recompute geometry lazily at the moment a client asks for a bounding rectangle rather than eagerly for the whole tree.

Recomputation is tied to the frame

The dirty set is processed before the platform events for that frame are dispatched, in the same rendering update that produces the new layout. The ordering matters: a client that receives a “children changed” event and immediately queries the children must see the post-change tree, not the pre-change one. Engines therefore finish recomputation first, then build the event list from the diff between the old and new structure, then dispatch.

The event list is built by comparing the previous tree’s object set with the new one. An object present in both with the same parent produces no event. An object present in the new tree but not the old produces a create event on the parent and on the object itself. An object in the old but not the new produces a destroy event. An object whose parent changed produces a re-parent event, which is the only way a client learns that aria-owns took effect or that an ignored wrapper was removed and its children spliced upward.

Because the diff is computed rather than recorded, intermediate states are invisible. If a script removes a node and re-inserts it in the same task, the diff sees an unchanged tree and emits nothing. That is the correct outcome for a client, and it is the source of the common debugging surprise where a mutation observer reports a change that no accessibility event reflects.

// Mutations inside one task collapse: the diff sees only the final state.
const list = document.getElementById('items');
list.replaceChildren();               // destroy events for old children
for (const item of next) {
  list.append(item);                  // create events for new children
}
// A client receives destroys then creates, not the intermediate empty state
// that existed between the two statements.

Coalescing and its limits

Repeated events of the same type on the same object are merged. Two name changes in one frame produce one name-changed event. A text insertion followed by a deletion within the same object produces one text-changed event covering the union of the affected range, which is why a client that applies the range to the string it holds can end up with a string that never existed at any instant.

Events of different types are not merged. A create followed by a name change on the same object produces two events, and a client may observe the create before the name is attached. Some engines delay the create until the object’s initial properties are computed, which avoids that ordering, and some do not. The difference is visible: a screen reader that announces the object on create may say “button” and then, a moment later, “Save”, or may say “Save, button” in one utterance.

There is a per-frame cap. When a single frame would produce a large number of events (a full-page replacement, for instance), an engine may collapse the batch into a subtree-changed event on the nearest common ancestor and let the client re-read the subtree. The threshold is implementation-defined, and it is the mechanism behind the behaviour where a large DOM swap produces one event and a small one produces hundreds.

Focus events are not coalesced in the same way. They are dispatched promptly because a client’s response to focus is the primary feedback loop for keyboard users, and a delayed focus announcement is worse than a duplicate one. An engine that batched focus would break the correspondence between the key press and the spoken result.

Live regions as an explicit queue

Live region announcements are the one case where authors control the coalescing. An element with aria-live="polite" accumulates text changes and the client announces them when it is idle; aria-live="assertive" interrupts. The attribute values off, polite, and assertive map to platform-level notification priorities in Core-AAM, and the mapping is where the interesting failures live.

A polite region that changes several times per second produces a queue that the screen reader cannot drain. Clients handle this by dropping superseded announcements: a change to a message that has not yet been spoken replaces the pending utterance instead of queueing behind it. That behaviour is client-specific and undocumented at the platform level. aria-atomic="true" forces the whole region to be re-read on any change, which is correct for a status field and catastrophic for a log.

The related attribute aria-relevant declares which change types matter: additions, removals, text, or all. A log region that sets additions text announces new lines and ignores removals; the default, additions text, is what most implementations use. A region that removes an item and expects it to be announced will not be, because removals are not in the default set. The priority values are carried to the platform layer as notification priorities, and the mapping of those properties is part of the same table that carries roles and states, described in Mapping ARIA Roles onto Platform Accessibility APIs.

Stale references and the generation problem

Clients hold references to objects, not to paths. A platform handle or a D-Bus object path identifies one object for its lifetime, and when the object is destroyed the handle becomes invalid. A client that caches a handle across a tree change must handle the invalidation, and the platform interfaces provide no way to ask whether a handle is still valid other than to use it and check for an error.

This is why aria-owns and aria-activedescendant are risky under heavy mutation. An owned element that is removed and re-added gets a new object, so a client holding the old handle now points at a destroyed object while the attribute still names the same ID. The same applies to aria-labelledby: the reference is by ID, the platform relation is by pointer, and the two can disagree after a re-render that replaced the labelled-by target.

The mitigation is to keep mutations that change the tree’s shape away from the paths that clients are actively using. Replacing the contents of a live region is safe because the region itself persists. Replacing the region’s container is not, because the region’s object is destroyed and a client’s reference to it is lost.

What the pipeline cannot see

Three classes of change never reach the event pipeline. A change to content that is not exposed produces no event, because there was no object. A change that leaves the tree identical produces no event, because the diff is empty. A change made while no client is attached may produce no event at all, because the tree may not have been built; when a client attaches, it reads the current state rather than a history of changes.

The third case is the one that breaks naive assumptions about ordering. A client that attaches after a scripted sequence has completed sees the final state with no events for the intermediate steps, so any logic that depends on having observed each step (a progress indicator that increments, a log that appends) will not work through the accessibility event stream alone. The alternative is for the page to expose the sequence as live region updates, which produce events for the text changes themselves rather than for the structural changes that carried them.

The cost model that follows is straightforward: invalidation is proportional to the number of dirty objects, event dispatch is proportional to the number of surviving differences, and client processing is proportional to what the client chooses to re-read. A page that mutates a small, named, persistent region costs a few events per frame. A page that rebuilds a large subtree costs a batch and a client re-read of that subtree, and no amount of author care changes the ratio.

References

  1. Core Accessibility API Mappings 1.2 — Events — W3C
  2. UI Events — W3C
  3. RenderingNG — Chromium rendering architecture — Chromium project documentation
  4. AT-SPI 2 — Event listeners — GNOME Accessibility project