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.

Announcement policy by stream type Matrix of three stream shapes against the moment each should announce and what the message should contain. Announcement policy by stream typeAnnounce whenMessageBounded streamthe stream completesthe final row countLong stream, knowntotalevery 25 percent, and on completionprogress and the totalUnbounded feedon a pause of 3 to 5 secondsrows added since the last announcement
A stream that never ends still needs an announcement policy.

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.

Batching a stream for speech Three-step batching strategy: buffer incoming rows, insert on a frame boundary, and announce only when the stream settles or pauses. Batching a stream for speechBuffer arrivalsrows collected in memory as they arriveNo DOM work per rowInsert per frameappend up to 50 rows on each frameKeeps the main thread and the tree stableAnnounce on settle"412 rows loaded"Or on a pause, for a stream with no end
Render in batches, speak once — the two cadences are not the same.

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
Batch size versus frames spent inserting Bar chart of the number of animation frames needed to insert 500 rows at four different batch sizes. Batch size versus frames spent inserting1 row per frame500 frames10 rows per frame50 frames50 rows per frame10 frames250 rows per frame2 frames
Batch size trades responsiveness against total insertion time.

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.

Permalink to "Related"

Back to Progressive Loading & Skeleton States