Announcement Queues for Competing Messages

Permalink to "Announcement Queues for Competing Messages"

An announcement queue is a small piece of application code that sits between everything that wants to speak and the page’s live regions. It decides which messages are delivered, in what order, and which are merged or dropped. Data interfaces need one because several sources fire at once: a user applies a filter, which triggers a count message, a totals message and a sort reset; meanwhile a background sync finishes and a save confirmation arrives. Written directly to one live region, each message overwrites the last and the user hears only whichever landed final — often the least important.

This page designs a queue with priorities, topic merging and staleness. It belongs to screen reader announcement strategies.

Spec reference

Permalink to "Spec reference"

ARIA says nothing about queuing: it defines live regions and politeness, and leaves delivery to assistive technology. In practice:

  • A polite region’s change is queued by the screen reader after current speech. If the region changes again before the first change is spoken, many readers speak only the latest text.
  • An assertive region interrupts current speech, including a polite message that was about to be spoken.
  • Two separate polite regions changing in the same tick are spoken in DOM order by some readers and only one of them by others.

So the application cannot rely on the screen reader to keep several messages. It must choose. SC 4.1.3 Status Messages requires that status messages be available to assistive technology; a message overwritten before it is spoken is not, in any practical sense.

Inside the queue Flow of a message through the announcement queue: submitted with priority and topic, merged with same-topic messages, ordered by priority, spaced for delivery, then written to the live region. Inside the queueSubmittext, priority,topicMergesame topic: keepnewestOrderurgent first, thenhigh, then lowSpaceone message per~700 msDeliverwrite to the statusregion
Four small decisions turn five colliding messages into two clear ones.

When to add a queue — and when not to

Permalink to "When to add a queue — and when not to"

Add one when a page has more than one independent source of status messages: a grid with sorting and saving, a dashboard with several refreshing widgets, a form that validates and autosaves. Once two sources can fire within a second of each other, collisions are inevitable.

A single-purpose page — a search results list whose only message is the result count — does not need one. A debounce on that one message is enough; see debouncing status messages for bulk operations.

The misapplication to name is solving collisions by giving each source its own live region. It moves the collision from your code into the screen reader, where you have no control over which region wins.

Annotated code example

Permalink to "Annotated code example"
// announce-queue.js — one queue per page, delivering to one status region
const PRIORITY = { urgent: 3, high: 2, low: 1 };
const MERGE_WINDOW = 250;     // ms: same-topic messages within this window merge
const SPACING = 700;          // ms: minimum gap between deliveries
const STALE = 4000;           // ms: low-priority messages older than this are dropped

const queue = [];
let busyUntil = 0;
let timer = null;
const statusEl = document.getElementById('sr-status');   // role="status"
const alertEl = document.getElementById('sr-alert');     // role="alert"

export function announce(text, { priority = 'low', topic } = {}) {
  const now = performance.now();
  if (priority === 'urgent') {                  // bypass: interrupts, SC 4.1.3
    write(alertEl, text);
    return;
  }
  if (topic) {                                  // merge: newest wins within the window
    const i = queue.findIndex((m) => m.topic === topic && now - m.at < MERGE_WINDOW);
    if (i !== -1) queue.splice(i, 1);
  }
  queue.push({ text, priority: PRIORITY[priority], topic, at: now });
  queue.sort((a, b) => b.priority - a.priority || a.at - b.at);
  schedule();
}

function schedule() {
  if (timer) return;
  const wait = Math.max(0, busyUntil - performance.now());
  timer = setTimeout(flush, wait);
}

function flush() {
  timer = null;
  const now = performance.now();
  // drop stale low-priority messages instead of delivering them late
  while (queue.length && queue[0].priority === PRIORITY.low && now - queue[0].at > STALE) queue.shift();
  const next = queue.shift();
  if (!next) return;
  write(statusEl, next.text);
  busyUntil = now + SPACING + next.text.length * 45;   // rough speech time
  if (queue.length) schedule();
}

function write(el, text) {
  el.textContent = '';
  requestAnimationFrame(() => { el.textContent = text; });
}
// Call sites choose intent; the queue decides delivery
announce('14 invoices shown.', { priority: 'high', topic: 'results' });
announce('Total 2,140.00 euros.', { priority: 'high', topic: 'totals' });
announce('Synced 2 seconds ago.', { priority: 'low', topic: 'sync' });
announce('Could not save INV-1042.', { priority: 'urgent' });

The spacing estimate — a base gap plus roughly 45 milliseconds per character — is deliberately crude. It only needs to be long enough that a short message is usually spoken before the next arrives; users who speed up their speech rate lose nothing, and users at slow rates occasionally hear a message cut short, which is still better than the unmanaged case.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Situation Without a queue With the queue
Filter → count + totals in one tick Only totals heard Count, then totals ~1 s later
Typing in a filter box (8 keystrokes) 8 count messages, most cut off 1 count message (merged by topic)
Background sync during a sort Sync message overwrites sort result Sort result first; sync dropped if stale
Save failure during typing Lost among status messages Alert interrupts immediately
Five messages, two deliveries and an alert Timeline of messages arriving within a second and what the queue delivers: two results messages merge, a sync message is dropped as stale, and an urgent save failure bypasses the queue. Five messages, two deliveries and an alert0 ms"12 shown" (results)120 ms"14 shown" replaces it150 ms"Total 2,140" queued900 ms"14 shown" delivered1.8 s"Total 2,140" deliveredAny timeurgent failure interruptsone filter change and a background sync
The user hears what matters, in order, and nothing after it stops being true.

Integration context

Permalink to "Integration context"

The queue delivers to the regions described in role status, alert and log compared: low and high priority to status, urgent to alert. In React, the announcer provider from live regions in React without lost announcements can wrap this queue so components still just call announce.

Dashboards are the heaviest users of queues, because every widget refreshes on its own schedule; announcing dashboard refreshes builds on this design with per-widget topics and a summary mode.

Priority levels and what belongs in each Matrix of the queue's three priority levels, example messages, delivery behaviour and whether stale messages are dropped. Priority levels and what belongs in eachPriorityExamplesDeliveryDropped if stale?urgentSave failed; session expiringImmediately, assertiveNeverhighResult counts; sort; savedNext slot, politeNolowBackground sync; presenceWhen idleYes, after 4 s
Most messages are "high" — results of what the user just did; "low" is for things nobody asked about.

Gotchas

Permalink to "Gotchas"

Merging across different meanings. Topic keys must be specific. “results” for the grid and “results” for a sidebar search will merge and one will vanish.

Spacing and fast readers. Some users speak at 600+ words per minute and find the spacing slow. Keep it modest and never delay urgent messages.

Queue survives navigation. In a single-page app, clear the queue on route change so a message about the previous page is not spoken on the next.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Why do screen readers skip some of my status messages?

Because several messages were written to live regions within a short time, and most screen readers speak only the latest text of a polite region. An announcement queue spaces messages out and merges or drops the ones that no longer matter.

Should different widgets use different live regions?

No. Separate regions still collide, and you cannot control which one the screen reader speaks. Route every message through one queue and one polite region, with one assertive region for urgent problems.

How long should the gap between queued messages be?

Long enough for a short message to be spoken — roughly 700 milliseconds plus a little per character is a practical start. Keep urgent messages outside the queue so they are never delayed.

What should happen to a status message that is no longer true?

Drop it. A background sync message delivered five seconds late, after the user has moved on, is noise. Mark such messages low priority and discard them once they are stale.

Permalink to "Related"

← Back to Screen Reader Announcement Strategies for Data Interfaces