DEV Community

babycat
babycat

Posted on

An Accessible Queue for Streaming Prompts Needs More Than a Per-Request Spinner

A screen reader user sends one prompt, then a second before the first finishes. In many chat UIs, two loading states appear side by side, and the live region either says nothing or announces Response complete twice in the wrong order. The user can no longer tell which answer belongs to which request, and keyboard focus may still sit on a Cancel button that now cancels the wrong stream.

The problem is not that concurrent requests are hard to implement. It is that most UIs treat each streaming request as an isolated fetch with its own spinner, while accessibility requires one coherent queue with a published order: queued, active, cancelled, failed, complete.

This post builds a minimal queue that keeps every transition keyboard-operable and understandable through a screen reader. It uses an AbortController per request, a typed state map, and a single aria-live region that announces state changes in a stable order. You can run the frontend against a local Node server or a free server option to test the cancellation path on a real network.

The failure: one live region, three overlapping announcements

Imagine a prompt queue rendered as a list:

<ul id="prompt-list" aria-label="Prompt queue"></ul>
Enter fullscreen mode Exit fullscreen mode

When the user submits two prompts quickly, the DOM might get two items:

<li data-request-id="a" data-status="active">
  <span>Summarize the checkout flow</span>
  <button>Cancel</button>
</li>
<li data-request-id="b" data-status="queued">
  <span>Explain the payment error</span>
  <button>Cancel</button>
</li>
Enter fullscreen mode Exit fullscreen mode

If every state update writes to the same live region without a sequence number, the screen reader may hear:

  1. Active: Summarize the checkout flow.
  2. Queued: Explain the payment error.
  3. Complete: Summarize the checkout flow.
  4. Active: Explain the payment error.

That order is technically correct, but the live region often collapses rapid updates, leaving only Complete: Summarize the checkout flow. or duplicating the Active announcement after the user has already moved focus. The result is that the queue looks right visually but is unreliable without sight.

Model the queue as typed states

The first fix is to stop storing promises and start storing request objects with an explicit status field. Create promptQueue.js:

const requestMap = new Map();
let nextId = 0;

export function enqueuePrompt(promptText, runPrompt) {
  const id = `req-${++nextId}`;
  const entry = {
    id,
    promptText,
    status: 'queued',
    controller: new AbortController(),
    runPrompt,
  };
  requestMap.set(id, entry);
  renderQueue();
  return id;
}

export function cancelPrompt(id) {
  const entry = requestMap.get(id);
  if (!entry) return;

  if (entry.status === 'queued') {
    entry.status = 'cancelled';
    requestMap.delete(id);
  } else if (entry.status === 'active') {
    entry.controller.abort();
  }
  renderQueue();
}

export function updateStatus(id, status) {
  const entry = requestMap.get(id);
  if (entry) {
    entry.status = status;
    renderQueue();
  }
}

export function getEntries() {
  return [...requestMap.values()];
}
Enter fullscreen mode Exit fullscreen mode

The key detail is that cancelPrompt handles a queued request differently from an active one: a queued request can be removed immediately, while an active request must go through AbortController. This distinction prevents the common bug where a queued request keeps the Cancel button active but nothing happens on click.

In renderQueue, map each entry to a list item and set data-status. Then announce the latest state change in a separate live region:

import { getEntries, cancelPrompt, updateStatus } from './promptQueue.js';

const list = document.querySelector('#prompt-list');
const live = document.querySelector('#prompt-live');

function renderQueue() {
  list.innerHTML = '';

  for (const entry of getEntries()) {
    const li = document.createElement('li');
    li.dataset.requestId = entry.id;
    li.dataset.status = entry.status;

    const label = document.createElement('span');
    label.textContent = entry.promptText;

    const cancelButton = document.createElement('button');
    cancelButton.textContent = 'Cancel';
    cancelButton.addEventListener('click', () => cancelPrompt(entry.id));

    li.append(label, cancelButton);
    list.append(li);
  }

  const latest = getLatestEntry();
  if (latest) {
    const announcement = `${latest.status}: ${latest.promptText}`;
    live.textContent = '';
    requestAnimationFrame(() => {
      live.textContent = announcement;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The live region uses role="status" and aria-atomic="true" so each announcement replaces the previous one rather than accumulating:

<div id="prompt-live" role="status" aria-atomic="true" aria-live="polite"></div>
Enter fullscreen mode Exit fullscreen mode

Because the announcement includes the request's own text, the user can distinguish Active: Summarize the checkout flow from Active: Explain the payment error. The state word is first so screen readers sort them consistently.

Run a real stream and let cancellation win

The frontend needs a runPrompt implementation that reads a streaming endpoint and updates the request entry. Use fetch with the entry's controller.signal:

export async function runPrompt(entry) {
  updateStatus(entry.id, 'active');
  try {
    const response = await fetch('/stream', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ prompt: entry.promptText }),
      signal: entry.controller.signal,
    });

    if (!response.body) throw new Error('No stream available');
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let result = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      result += decoder.decode(value, { stream: true });
      updateEntryText(entry.id, result);
    }
    updateStatus(entry.id, 'complete');
  } catch (error) {
    if (error.name === 'AbortError') {
      updateStatus(entry.id, 'cancelled');
    } else {
      updateStatus(entry.id, 'error');
    }
  } finally {
    requestMap.delete(entry.id);
    renderQueue();
  }
}
Enter fullscreen mode Exit fullscreen mode

The finally block removes the entry after the status is final. This avoids keeping cancelled or complete requests in the list, which otherwise confuses screen reader users who page through old items.

To test the cancellation path without waiting for a real model, create a small Node server that streams SSE chunks and honors abort:

import { createServer } from 'node:http';

createServer((req, res) => {
  if (req.url === '/stream') {
    res.writeHead(200, {
      'content-type': 'text/event-stream',
      'cache-control': 'no-cache',
      connection: 'keep-alive',
    });

    let count = 0;
    const timer = setInterval(() => {
      count += 1;
      res.write(`data: chunk ${count}\n\n`);
      if (count >= 20) {
        clearInterval(timer);
        res.end();
      }
    }, 200);

    req.on('close', () => {
      clearInterval(timer);
      res.end();
    });
  }
}).listen(3000);
Enter fullscreen mode Exit fullscreen mode

When the user cancels, the frontend calls entry.controller.abort(), the server's req.on('close') fires, and the interval is cleared. The fetch rejects with AbortError, and updateStatus announces Cancelled: Follow-up question.

Test matrix: what to verify before shipping

A queue is only accessible if every transition works without a mouse and with a screen reader active. Use this matrix as a baseline:

Step Expected keyboard outcome Expected screen reader announcement
Focus the prompt input and type a question, press Enter Focus moves to the new Cancel button for that request Queued: question text
Press Enter again with a different question before the first completes Second request is queued; focus stays on its Cancel button Queued: second question text
Press Space or Enter on the Cancel button of the active request The active request aborts; focus returns to the prompt input Cancelled: active question text
Start a new request, then click outside the queue and press Escape The newest request cancels; no orphan focus stays on a removed button Cancelled: newest question text
Let a request complete without cancelling Final text appears; focus remains where the user left it Complete: question text

Run this with at least one screen reader and browser pair, such as NVDA with Firefox and VoiceOver with Safari. Pay special attention to the Cancelled announcement: if the live region says Cancelled while the network request is still active, the signal was lost.

Where a free server option fits

This is where a free server option becomes practical. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option, which is useful here because you can point the /stream endpoint at a real streaming model instead of the local SSE stub. The local stub is excellent for isolating cancellation logic, but a real endpoint verifies that your server-side proxy also forwards the abort signal—something a local interval cannot catch.

Replace the local server URL with your free server endpoint, keep the same frontend, and run the cancellation test again. If the request still rejects with AbortError, the proxy is forwarding the signal. If the fetch resolves after the button says Cancelled, you have found a server-side bug that would be invisible in the local-only test.

This approach is not a benchmark and does not tell you anything about model quality. It only verifies that cancellation and queue announcements behave the same on a real network as they do locally. If you do not have a free server option available, the local SSE server above is enough to validate the frontend logic.

Who should skip this

  • Teams that never allow concurrent streaming requests do not need a queue; a single typed state machine per request is enough.
  • If your product already has a mature request queue library, adopting this hand-rolled map may duplicate logic.
  • If you only need to test the UI layer and have complete control over the backend, a local stub may be sufficient; the free server is an integration check, not a replacement for unit tests.

The core lesson is simple: multiple asynchronous streams do not make a UI inaccessible by themselves. What breaks the experience is treating each request as its own spinner while ignoring the published order of queued, active, cancelled, and complete. When the announcement order matches the actual state, a screen reader user can cancel the right request at the right time, and the Cancel button stops lying.

Top comments (0)