Announcing Streamed Rows as Data Hydrates
Permalink to "Announcing Streamed Rows as Data Hydrates"A table that fills over several seconds from a stream has two independent cadences: the rate rows arrive, and the rate a person can be told about them. Conflating the two is what produces the classic streaming failure — a screen reader reading four hundred individual row insertions while the user waits for a single useful sentence. This page covers the batching strategy that keeps rendering smooth, the announcement policy for bounded and unbounded streams, and the row counts that must describe the data rather than the DOM.
It is the streaming detail beneath progressive loading and skeleton states, and it shares its throttling discipline with throttling high-frequency aria-live updates.
Spec reference
Permalink to "Spec reference"SC 4.1.3 Status Messages (Level AA) governs the announcements. SC 2.2.1 Timing Adjustable (Level A) governs any stream that continues indefinitely: automatically updating content must be pausable unless the updating is essential.
The ARIA attributes involved are aria-busy on the container while rows are written, aria-rowcount on the table to state the true total, and a role="status" region outside the busy subtree for the messages. There is deliberately no attribute that means “rows are arriving” — that state belongs in a sentence, not an attribute.
Default behaviour with no intervention is poor in a specific way: each inserted row is a DOM mutation, and if any ancestor carries aria-live, every one of them is announced. Without a live region the rows are silent, which is better but still leaves the user with no idea the table has finished.
When to use — and when not to
Permalink to "When to use — and when not to"Use batched hydration whenever rows arrive incrementally: a streamed HTTP response, a WebSocket backfill, a paged endpoint being drained. The strategy is the same in all three cases.
Do not batch when the whole result arrives at once. A single tbody replacement is one mutation and needs no batching, and adding it introduces frames of partially rendered state for no benefit.
Do not announce progress on a fast stream. If hydration completes in under a second, one settle message is the entire correct output; progress messages for a wait nobody noticed are noise.
Annotated code example
Permalink to "Annotated code example"// Buffer, insert per frame, announce on settle — three separate cadences
const BATCH = 50;
let buffer = [];
let inserted = 0;
let settleTimer = null;
table.setAttribute('aria-busy', 'true');
table.setAttribute('aria-rowcount', String(knownTotal ?? -1)); // -1 = unknown
for await (const row of stream) {
buffer.push(row);
if (buffer.length >= BATCH) flushToDom();
scheduleSettle();
}
flushToDom();
settle();
function flushToDom() {
if (!buffer.length) return;
const frag = document.createDocumentFragment();
for (const row of buffer) frag.appendChild(renderRow(row, ++inserted));
tbody.appendChild(frag); // ONE DOM mutation per batch, not per row
buffer = [];
}
function scheduleSettle() {
clearTimeout(settleTimer);
settleTimer = setTimeout(settle, 3000); // a pause counts as a settle
}
function settle() {
table.setAttribute('aria-busy', 'false');
requestAnimationFrame(() => { // the frame gap VoiceOver needs
status.textContent = knownTotal
? `${inserted} of ${knownTotal} rows loaded`
: `${inserted} rows loaded`;
});
}
Using a DocumentFragment means each batch is a single insertion into the live DOM rather than fifty. That matters for the accessibility tree as much as for layout: fifty separate mutations are fifty separate events the platform has to process and the screen reader has to consider.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Situation | Expected behaviour | Failure indicator |
|---|---|---|
| Rows arriving | No announcements at all | Each row read aloud — a live region is on an ancestor |
| Stream settles | One message with the count | Silence — aria-busy was never cleared |
| Focus inside the table | Focus stays on its row | Focus lost — the table was re-rendered instead of appended to |
| Stream pauses indefinitely | A message after the pause window | The user waits forever for a stream that stopped |
| Unbounded feed | A pause control is reachable | SC 2.2.1 failure |
Integration context
Permalink to "Integration context"Streaming hydration and virtualization interact directly. If the list is windowed, appending rows to the data array does not append them to the DOM, so the batching strategy applies to the array and the announcement policy stays the same — but aria-rowcount becomes the only way the user learns the set is growing. Update it on every batch.
Where the stream is a live feed rather than a backfill, the announcement policy from real-time data stream announcements takes over: a fixed cadence with coalesced summaries, plus the pause control SC 2.2.1 requires.
Gotchas
Permalink to "Gotchas"Announcing per batch. Ten batches produce ten announcements, which is better than four hundred and still nine too many.
Re-rendering instead of appending. Frameworks that rebuild the list from the full array on each batch destroy focus and re-announce content. Append; do not replace.
aria-rowcount reflecting the DOM. Setting it to the number of rows currently inserted tells the user the set is complete when it is not. Use the true total, or -1 while the total is unknown.
A stream that stops without completing. Network stalls produce a table that is neither loading nor loaded. The settle timer covers this: a pause is treated as a settle, and the count is announced honestly as what arrived.
FAQ
Permalink to "FAQ"How often should a streaming table announce?
Once, when it settles, for a bounded stream. For a long stream with a known total, progress announcements at quarter points are useful and are still only four messages. For an unbounded feed, announce on a pause of three to five seconds and describe what arrived since the last announcement. What never works is announcing per row or per batch.
What batch size should rows be inserted in?
Enough that insertion finishes quickly and few enough that a frame is not blocked. Around 50 rows per frame suits most tables: 500 rows arrive in ten frames, roughly 160 milliseconds, without a visible stall. Very large batches block the main thread, which delays the screen reader as well as the paint.
Should aria-rowcount update during the stream?
Yes, if the total is known, set it up front — it is the total row count of the data, not of what has been rendered. If the total is discovered as the stream progresses, update it on each batch. Either way it should never equal the number of rows currently in the DOM unless those really are all the rows.
Related
Permalink to "Related"- Progressive loading & skeleton states — the parent guide and the state machine
- Using aria-busy during progressive table loads — the attribute that makes batching silent
- Throttling high-frequency aria-live updates — the same discipline for a feed that never ends