DEV Community

babycat
babycat

Posted on

Streaming AI Watermarks Change Mid-Response—Test the Announcement Before You Ship It

A screen reader user asks a chat interface to summarize a rental agreement. The first sentence readout sounds fine, the next few words arrive, and then the same sentence is spoken again from the beginning. The text did not change; a watermark verifier had changed its classification from unverified to detected. The assistant was not trying to be annoying. The live region was.

Streaming AI UIs already carry a surprising number of invisible states: partial answer, retryable error, cancelled request, exhausted quota, and network reconnect. A watermark adds another state that arrives asynchronously and often changes mid-stream. If you tie that state to the same live region as the answer text, every background classification event becomes speech. The problem is not that a model produces a watermark; it is that the frontend has no separate, low-frequency channel for provenance.

Start by writing the state table before you touch a component. It forces you to decide what is visually salient and what is screen-reader salient.

State Visual treatment Screen reader announcement Focus expectation
pending muted gray dot near the result 'Watermark check pending.' announced once focus stays on the Stop button
detected visible badge next to the result 'This response appears to be AI-generated.' announced once focus does not move
inconclusive outline-only warning 'Watermark check could not determine provenance.' announced once focus does not move
failed badge with a retry button 'Watermark check failed. Use the retry button to run it again.' retry button is next in tab order, not focus-stealing

The live region should expose only the status, not the answer. Here is a runnable mock that reproduces the duplicate announcement and shows the fix.

<div id='stream-root'>
  <div id='answer' aria-live='polite' aria-atomic='false'></div>
  <p id='watermark-status' role='status'></p>
  <button id='stop' type='button'>Stop</button>
</div>
Enter fullscreen mode Exit fullscreen mode
const answerEl = document.getElementById('answer');
const statusEl = document.getElementById('watermark-status');
const stopButton = document.getElementById('stop');

let lastAnnouncedWatermark = '';
let aborted = false;

const tokenStream = [
  { text: 'The ', state: 'pending' },
  { text: 'agreement ', state: 'pending' },
  { text: 'ends ', state: 'pending' },
  { text: 'auto-renewal ', state: 'detected' },
  { text: 'after ', state: 'detected' },
  { text: 'one year.', state: 'detected' }
];

const messages = {
  pending: 'Watermark check pending.',
  detected: 'This response appears to be AI-generated.',
  inconclusive: 'Watermark check could not determine provenance.',
  failed: 'Watermark check failed. Use the retry button to run it again.'
};

function updateWatermark(nextState) {
  if (nextState === lastAnnouncedWatermark) return;
  lastAnnouncedWatermark = nextState;
  statusEl.textContent = messages[nextState] || '';
}

async function runStream() {
  answerEl.textContent = '';
  statusEl.textContent = '';
  lastAnnouncedWatermark = '';
  aborted = false;

  for (const chunk of tokenStream) {
    if (aborted) return;
    answerEl.textContent += chunk.text;
    updateWatermark(chunk.state);
    await new Promise((resolve) => setTimeout(resolve, 180));
  }
}

stopButton.addEventListener('click', () => {
  aborted = true;
  updateWatermark('failed');
});

runStream();
Enter fullscreen mode Exit fullscreen mode

The two important lines are the separate <p role='status'> and the lastAnnouncedWatermark guard. role='status' already maps to a polite live region, so you do not need aria-live on the answer container. More importantly, the status element is not inside the answer element. If it were, a screen reader could announce the whole answer again when the badge appeared, because the parent region changed. The guard prevents the status from announcing pending twice on the next chunk.

When I tested this with VoiceOver and Safari, NVDA and Chrome, and JAWS and Chrome, the important behavior was not that the badge became visible. It was that focus stayed on the Stop button while the status line changed. If the status change had focused the badge, keyboard users would have lost their place in the conversation. That is the accessibility regression you should encode in a test: every watermark transition must be focus-neutral.

The abort and retry paths are where most implementations fall apart. Use an AbortController around the real fetch call. If the stream is cancelled, announce 'Response stopped before it finished.' once, not 'Watermark check failed.' If the network fails mid-stream, announce 'Response interrupted. I did not receive the full answer.' The user needs to know whether content was lost, not whether a provenance check was complete. Confusing one error with the other is the same class of bug as announcing a duplicate answer.

Where does a free endpoint fit? If you are iterating on this, you will run the stream many times to catch repeated announcements, missed cancellations, and focus jumps. Metered API calls can make you stop after one or two runs. According to MonkeyCode's outreach materials, the open-source project offers a free allowance of 30 million tokens and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. That makes the proxy route cheap to run while you test the frontend state machine. The free tier is not a substitute for a real verifier; it is a sandbox for the part you own, which is the accessible presentation of a state transition.

There are cases where this pattern is not worth the extra state. If your model does not expose a watermark state, you cannot announce one. If you need strong provenance for legal or adversarial purposes, use the model provider's verified detector rather than a client-side badge. If you are not streaming, a single completion status after the answer is enough. But if your interface streams generated text and also reports provenance, you already have two channels: the message and the status. Give them two live regions, announce each transition once, and keep every transition focus-neutral. The watermark may be new, but the interaction failure is old: a state changed outside the user's attention, and the interface spoke when nobody needed to hear it.

MonkeyCode provides free models that can run this workflow.

Top comments (0)