Build1 publisher3 min readPublished
Reusing one live-region node on retry glued a cancelled chat answer to the new one
Writing a retry into the same live-region node made a screen reader read the cancelled chat fragment and the new answer as one paragraph, a dev.to repro found. The author traces it to node identity and argues each retry needs its own announcement and an immediate rename of the Stop button.
The Engineer · Build desk

What happened
- In a single-file chat demo, the author cancelled a stalled answer with Escape, pressed Retry, and heard the cancelled fragment and the new sentence read as one paragraph.
- The transcript paragraph also carried a polite live region, and on retry the demo cleared its text and wrote the new tokens into that same node.
- Stop and Retry were one button whose accessible name came from a closure captured at stream start, so after cancel the next Enter still meant Stop.
- The author's state contract requires every retry to increment a generation integer before any token of the new attempt is allowed to arrive.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Chat UIs that stream a retry into the same live-region node leave users of screen readers that key on node identity hearing the retry as a continuation of the cancelled answer.
- decision Keeping focus on the button, as the author insists, rules out renaming it on a later render; the name change has to happen inside the cancel handler itself.
- capability Because the attempt number lives in page state, the UI can drop a cancelled stream's late tokens without depending on how any screen reader treats a rewritten node.
The author's diagnosis turns on which node the screen reader is watching. "Several readers decide what to speak from node identity, not from the string you wish they would forget," the author wrote [5]. The timer log supports it. After the retry it showed generation two, while the live node still held generation-one text with the new clause attached [10]. Comparing the announcer reference before and after the retry settled the question. The reference never changed, so assistive technology had no new node to treat as a fresh message [11].
A sighted check passed the whole time. "The pixels showed a tidy replacement, but the accessibility tree still carried the previous generation of text," the author wrote [15]. And: "I trusted the visual reset for too long, because my eyes could not hear the glued sentence" [16].
The button fault comes from a choice the author kept on purpose. Focus stayed on the button after cancel, and the author did not want it moved to the composer, because that hides the control the user just used [7]. The spoken name said Stop even though the request had already failed and the composer was idle [2]. Reading the button text in the same turn as the cancel confirmed it still said Stop, with the update waiting on the stale closure [12].
The third check tried more ARIA. Setting aria-atomic on the growing transcript made the duplication louder, so the author ruled it out as the missing fix [13]. None of the three checks needed a framework profiler or a production trace [18].
The repair starts from a contract the author wrote down before changing another line of the demo, and tested against [20]. It has four phases, idle, streaming, cancelled and failed, plus one rule for stragglers: a late token whose generation does not match is ignored [1][8]. "That single integer saved me from arguing with the screen reader about which sentence it should forget," the author wrote [17]. I think this is the right design for streamed output. The attempt boundary becomes a value the page owns and can log, as the timer log did with phase, generation and announcer text after every event [10].
Two conditions in the demo decide whether this applies to another chat UI. The transcript paragraph carried the live region itself, and the button's name came from state captured at stream start [4][6]. If your announcer is already a separate element from your transcript, the first symptom rests on a setup you do not have. The race was reproduced with a local timer and no network in the loop, and the author describes it as a reproduction, not a production incident [10][3]. The post does not name the screen readers or browsers it was tested with. On whether the bug was a screen reader quirk or the author's own tree, the author wrote: "Both can be true on a given browser, and I still owned the tree I was shipping" [14].
What to watch
- A tested list of screen readers and browsers from the author, showing whether node identity drives announcements in all of them or only some.
- A repro over a real network stream, where late tokens from a cancelled request arrive on real timing instead of a local timer.