DEV Community

babycat
babycat

Posted on

Test Streaming Markdown for Transient Headings and Half-Written Links

This write-up reconstructs a debugging session against a single-file chat transcript, not a production incident with traffic numbers. I had NVDA running beside a streaming answer when the voice said heading level two, To. The model was still generating Token budgets, and my renderer had already promoted that first word into a real heading. Have you ever tabbed onto a link whose href was still https://api. and then followed it into a dead tab?

That is the failure this article fixes, and it shows up whenever markdown is parsed on every token. Visual users see a heading jump, then collapse, then jump again as characters arrive. Keyboard users get a tab stop that should not exist yet, and screen reader users hear structure that the author has not committed. I want a renderer that keeps incomplete syntax in a draft block until the block is actually closed.

The interaction that actually broke

I was checking a transcript the way a keyboard user would, not the way a mouse user would. The final screenshot looked fine, because the finished markdown was valid, pretty, and fully linked. The problem lived in the transitions, which is where assistive technology spends most of its time. If you only audit the completed answer, you will ship a stream that shouts fake structure for several seconds.

Here is the sequence I could reproduce on every slow stream:

  1. The model emitted # then a space, and the DOM grew an empty heading.
  2. NVDA announced heading level one, with no accessible name worth hearing.
  3. Later tokens filled in Retry UX, so the heading name changed under the reading cursor.
  4. A later chunk opened [guide](https://examp and a focusable link appeared with a truncated URL.
  5. I pressed Tab from the composer, landed on that link, and activated a navigation that 404ed.

Does your streaming preview run markdown(buffer) on each onmessage? Then you already own this class of bug, even if the happy path looks polished.

State table before any ARIA

I put the states on one card before touching attributes, because attributes cannot rescue a wrong tree. If a state cannot explain what Tab does, it is not finished work.

State Buffer shape DOM Screen reader Keyboard
idle empty no article body silent only the composer
streamingDraft open block plain text draft, aria-busy="true" polite "still writing" once draft contains no links or headings
blockCommitted closed block real heading, list, or link announce the new block once committed links enter tab order
streamFailed frozen buffer draft plus error status assertive error focus moves to Retry
complete full document parsed markdown "answer complete" normal reading order

I kept streamingDraft from minting headings on purpose, even when the draft started with a hash character. A hash is a character until a blank line, a closing fence, or the stream itself says the block is done. Why should # To become a landmark before the model has finished the title?

Debugging technique: freeze the chunk, not the screenshot

Happy-path screenshots lie, because they capture the committed document after the ugly transitions have vanished. I needed the in-flight buffer, the live DOM, and the utterance log at the same timestamp. The reusable part is not markdown lore. It is a loop you can run on any streaming widget.

Dump headings after every token

After each chunk I printed every heading and link, then compared the dump with the previous one. The smoking gun was a heading whose textContent changed five times without leaving the accessibility tree. A link node appeared at one chunk with href="https://examp" and only later became a real URL.

// Proposed probe: paste into DevTools while a stream is running.
window.__mdProbe = setInterval(() => {
  const nodes = [...document.querySelectorAll("h1,h2,h3,a[href]")];
  console.table(nodes.map((el) => ({
    tag: el.tagName,
    text: (el.textContent || "").slice(0, 48),
    href: el.getAttribute("href"),
    tabIndex: el.tabIndex
  })));
}, 200);
Enter fullscreen mode Exit fullscreen mode

Replay at rude cadences

Local setInterval pumps often emit whole words, which hides the bug. I replayed the same recorded chunks at 50ms, 400ms, and a "wait three seconds then flush" cadence. Mid-token splits are the interesting case, because that is where parsers invent structure. Record the raw chunk list, including delays, instead of concatenating immediately in the view.

Reusable steps I will keep using on other streaming widgets:

  1. Record the raw chunk list, including delays, instead of concatenating in the view.
  2. Replay those chunks at fast, slow, and stall-then-flush cadences.
  3. After every chunk, dump headings and links and diff them against the previous dump.
  4. Tab from the composer after each dump, and note whether focus landed inside the transcript.
  5. Repeat with the screen reader running, and write down the exact utterance after each chunk.

Root cause: a full parser on an incomplete buffer

The renderer looked innocent. It stored tokens in a string and re-parsed the whole string on every chunk. That is a document parser being asked to parse a sentence that is still being typed.

// Broken: proposed anti-pattern, do not ship this.
function renderBroken(buffer) {
  article.innerHTML = markdownToHtml(buffer);
}
Enter fullscreen mode Exit fullscreen mode

Markdown parsers are document parsers. They will treat # To as a heading because that is valid CommonMark so far. They may treat [n](http://x as a link if the implementation is hungry, or as odd text if it is strict, and you cannot QA both with a final snapshot. InnerHTML replacement also destroys any reading cursor that was inside the article, which is a second bug stacked on the first.

I did not need a new ARIA pattern here. I needed to stop emitting semantic HTML for syntax the model had not closed. Copy buttons, citation chips, and source links make it worse when they mount on that half-written HTML, because they add extra tab stops to a moving target.

The fix: commit closed blocks, keep a non-semantic draft

Think of the stream as a conveyor, not as a document. Closed blocks fall off the conveyor into real HTML. The open tail stays in a paragraph or preformatted node that is not a heading, not a list, and not a link. Keyboard users should not be able to tab into a URL the model is still spelling.

/**
 * Proposed splitter for a demo transcript, not a CommonMark implementation.
 * Commit when we see a blank line, a closed fence, or stream completion.
 */
function splitCommitted(buffer, streamOpen) {
  const fence = (buffer.match(/```
{% endraw %}
/g) || []).length;
  const insideFence = fence % 2 === 1;
  if (insideFence && streamOpen) {
    const lastOpen = buffer.lastIndexOf("
{% raw %}
```");
    return {
      committed: buffer.slice(0, lastOpen),
      draft: buffer.slice(lastOpen)
    };
  }

  const parts = buffer.split(/\n{2,}/);
  if (streamOpen && !buffer.endsWith("\n\n")) {
    const draft = parts.pop() ?? "";
    return { committed: parts.join("\n\n"), draft };
  }

  return { committed: buffer, draft: "" };
}
Enter fullscreen mode Exit fullscreen mode

The view then has two regions with different contracts. Committed HTML may contain headings and links. Draft text must remain characters.

<article aria-labelledby="answer-label">
  <h2 id="answer-label">Assistant answer</h2>
  <div data-committed></div>
  <p data-draft aria-busy="true"></p>
  <p class="sr-only" data-live aria-live="polite" aria-atomic="true"></p>
</article>
Enter fullscreen mode Exit fullscreen mode

Rules I encoded in the renderer, then tested by Tab and by utterance:

  • Parse markdown only for committed, never for draft.
  • Render draft with textContent, so hashes and brackets stay characters.
  • Keep aria-busy="true" on the draft while the stream is open, then remove it.
  • Announce a block once on commit, through a polite live region, not per token.
  • Do not put aria-live on the committed markdown itself, or you will queue every list item.
  • On streamFailed, freeze both regions, expose an assertive error, and move focus to Retry.
  • Mount copy buttons and citation chips only on committed blocks.

Expected UI states

I wrote the visual and non-visual states as a checklist, because a screenshot cannot show Tab order. Walk these states with the keyboard before you argue about typography.

  • Idle: transcript empty, composer focused, no busy state, no live text.
  • First tokens: draft paragraph shows characters, no heading in the accessibility tree, Tab skips the draft.
  • Blank line arrives: last draft promotes into committed HTML, live region speaks once, draft starts the next tail.
  • Fenced code: the opening fence stays in draft until the closing fence, so a copy button cannot appear on a half pre.
  • Failure: draft remains readable text, Retry is the next tab stop after the error, composer is not silently cleared.
  • Complete: draft node empty or removed, aria-busy gone, "Answer complete" announced once.

If a citation chip appears during streamingDraft, keyboard users will activate a control whose target is still being invented. That is the same family of failure as the half-written link, just wearing a prettier costume.

A slow stream is the test fixture, not a luxury

Local token pumps hide this bug, because they often emit whole words or whole lines. Real model streams split in the middle of ##, in the middle of a URL, and in the middle of a fence. You want a network that is a little rude, so your splitter is forced to hold an open tail.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project that currently offers free model access and a free server option, which is relevant here only as a remote chunk source with uneven cadence. Point the demo stream URL at that free server when you want delays and split tokens you did not hand-author, then rerun the heading dump after every chunk.

I am not attaching model names, quotas, hardware, or uptime claims, because those change and I cannot freeze them in a blog post. The workflow is the point: replay real chunks, dump headings and links after each chunk, and refuse to parse the open tail. If you already buffer markdown until the stream completes, you do not need a free remote server for this particular bug.

Minimal reproduction

The proposed demo below is a single-file page. It is labeled proposed because I am not publishing a lab audit with assistive-technology certificates. Serve it from any static directory, then toggle USE_BROKEN to watch the heading tree appear too early.

npx --yes serve .
Enter fullscreen mode Exit fullscreen mode
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Committed-block streaming markdown</title>
  <style>
    :root { font-family: system-ui, sans-serif; max-width: 42rem; }
    [data-draft] { opacity: 0.82; white-space: pre-wrap; }
    .sr-only {
      position: absolute; width: 1px; height: 1px; overflow: hidden;
      clip: rect(0 0 0 0);
    }
    .error { color: #8b1e1e; }
    button:focus, textarea:focus { outline: 3px solid #222; outline-offset: 2px; }
  </style>
</head>
<body>
  <h1>Streaming markdown transcript</h1>
  <form id="composer">
    <label for="prompt">Prompt</label>
    <textarea id="prompt" rows="2">Explain retry UX.</textarea>
    <button type="submit">Send</button>
    <button type="button" id="cancel" hidden>Cancel</button>
  </form>
  <p id="status" role="status"></p>
  <article aria-labelledby="answer-label">
    <h2 id="answer-label">Assistant answer</h2>
    <div id="committed"></div>
    <p id="draft" data-draft hidden></p>
    <p id="live" class="sr-only" aria-live="polite" aria-atomic="true"></p>
  </article>
  <p id="fail" class="error" hidden tabindex="-1"></p>
  <button id="retry" type="button" hidden>Retry last prompt</button>
  <script>
    const USE_BROKEN = false; // true = parse the whole buffer on every token
    const $ = (id) => document.getElementById(id);
    let abortOwner = null;
    let lastPrompt = "";

    function splitCommitted(buffer, streamOpen) {
      const fence = (buffer.match(/```
{% endraw %}
/g) || []).length;
      if (fence % 2 === 1 && streamOpen) {
        const lastOpen = buffer.lastIndexOf("
{% raw %}
```");
        return { committed: buffer.slice(0, lastOpen), draft: buffer.slice(lastOpen) };
      }
      const parts = buffer.split(/\n{2,}/);
      if (streamOpen && !buffer.endsWith("\n\n")) {
        const draft = parts.pop() ?? "";
        return { committed: parts.join("\n\n"), draft };
      }
      return { committed: buffer, draft: "" };
    }

    function escapeHtml(s) {
      return s.replace(/[&<>"']/g, (c) => ({
        "&": "&amp;", "<": "&lt;", ">": "&gt;",
        '"': "&quot;", "'": "&#39;"
      }[c]));
    }

    function committedToHtml(md) {
      return escapeHtml(md)
        .replace(/^## (.+)$/gm, "<h3>$1</h3>")
        .replace(/^# (.+)$/gm, "<h3>$1</h3>")
        .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>')
        .replace(/\n\n/g, "</p><p>")
        .replace(/^/, "<p>")
        .replace(/$/, "</p>");
    }

    function render(buffer, streamOpen) {
      if (USE_BROKEN) {
        $("committed").innerHTML = committedToHtml(buffer);
        $("draft").hidden = true;
        return;
      }
      const { committed, draft } = splitCommitted(buffer, streamOpen);
      $("committed").innerHTML = committed ? committedToHtml(committed) : "";
      $("draft").hidden = !draft;
      $("draft").textContent = draft;
      $("draft").setAttribute("aria-busy", streamOpen ? "true" : "false");
    }

    async function* fakeSlowStream(signal) {
      const text =
        "## Retry UX\n\nUse a recoverable error, not a disabled composer.\n\n" +
        "Read the [guide](https://example.com/retry).\n\nDone.";
      let buffer = "";
      for (const ch of text) {
        if (signal.aborted) throw new DOMException("Aborted", "AbortError");
        buffer += ch;
        await new Promise((r) => setTimeout(r, 40));
        yield buffer;
      }
    }

    async function runStream(prompt) {
      lastPrompt = prompt;
      const controller = new AbortController();
      abortOwner = controller;
      $("cancel").hidden = false;
      $("retry").hidden = true;
      $("fail").hidden = true;
      $("status").textContent = "Streaming";
      $("live").textContent = "Answer streaming";
      let lastCommitted = "";
      let buffer = "";
      try {
        // Swap fakeSlowStream for fetch(STREAM_URL) when you have a remote free server.
        for await (buffer of fakeSlowStream(controller.signal)) {
          render(buffer, true);
          const { committed } = splitCommitted(buffer, true);
          if (!USE_BROKEN && committed && committed !== lastCommitted) {
            $("live").textContent = "New block committed";
            lastCommitted = committed;
          }
        }
        render(buffer, false);
        $("status").textContent = "Complete";
        $("live").textContent = "Answer complete";
        $("draft").removeAttribute("aria-busy");
      } catch (err) {
        if (err.name === "AbortError") {
          $("status").textContent = "Cancelled";
          $("live").textContent = "Generation cancelled";
        } else {
          $("fail").hidden = false;
          $("fail").textContent = "The answer stopped before the last block committed. Retry is available.";
          $("fail").focus();
          $("retry").hidden = false;
        }
      } finally {
        $("cancel").hidden = true;
        abortOwner = null;
      }
    }

    $("composer").addEventListener("submit", (e) => {
      e.preventDefault();
      runStream($("prompt").value);
    });
    $("cancel").addEventListener("click", () => abortOwner?.abort());
    $("retry").addEventListener("click", () => runStream(lastPrompt));
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The tiny committedToHtml helper is a demo subset, not a sanitizer and not a markdown library. Treat any HTML built from model text as untrusted, even after you escape, if you later expand the subset. I kept headings as h3 under the labeled article so the page outline does not grow fake h1 nodes during a commit.

Proposed QA matrix

I did not run a certified assistive-technology lab for this post, so the matrix is a proposed checklist rather than a result table. Fill the last column with pass or fail only after you hear the utterance yourself.

Environment Assistive tech Transition to fail on purpose Pass if
Windows, Chrome current NVDA current first # character arrives no heading announcement until the title block commits
Windows, Firefox current NVDA current truncated https://examp in an open link Tab from composer does not land on that URL
macOS, Safari current VoiceOver current innerHTML replacement of committed region reading cursor is not thrown to the top of the page
iOS, Safari current VoiceOver current stream error after a half fence Retry takes focus, draft stays readable text
Keyboard only, any desktop none copy button or citation chip those controls exist only after the block commits

Invite yourself to be unkind. The interesting transition is not "answer complete." It is the first hash, the first open parenthesis of a URL, and the first backtick run that has no closing fence yet.

Limitations, and who should skip this

This splitter is a teaching artifact. It does not implement CommonMark, GFM tables, setext headings, indented code, or nested fences inside fences. It will mis-commit if a model streams one huge paragraph with no blank lines, because the open tail stays draft until completion. That is safer than inventing headings, but it is not a full streaming markdown engine.

Who should not use this approach:

  • Teams that only ever render plain text transcripts, because there is no heading tree to invent.
  • UIs that already buffer the full answer and parse once, because they already avoided this bug.
  • Anyone who needs a certified markdown implementation, because this subset will lie on edge cases.
  • Voice-only agents, because barge-in and speech cancellation are a different state machine.
  • Production apps that would treat this snippet as a sanitizer, because it is not one.

The free remote stream is also optional. If your recorded chunks already split inside headings and URLs, you can replay them from a fixture file and skip the network. I would rather have a mean fixture than a pretty live demo that always emits whole lines.

What I want you to reproduce

If you try the page, do not send me a screenshot of the finished answer. Send the browser, OS, and assistive-technology versions, plus the exact chunk where a heading or link appeared too early. Did Tab leave the composer before a blank line arrived? Did the live region stay quiet until a block actually committed?

Streaming markdown is not a visual polish problem. It is a state machine that must refuse to publish structure the model has not finished saying.

Top comments (0)