A Replacement Character Ate My Last Stream Event: Debugging UTF-8 Chunk Boundaries
A QA screenshot landed in my inbox on a Friday: the assistant had greeted someone with Good morοΏ½ning π , and the spinner under the message never stopped. On my laptop, over office fiber, the same conversation rendered per
A QA screenshot landed in my inbox on a Friday: the assistant had greeted someone with Good morοΏ½ning π
, and the spinner under the message never stopped. On my laptop, over office fiber, the same conversation rendered perfectly every single time. So what does a network have to do with a character that does not exist in any alphabet?
That question turned into two days of work, one of which I spent blaming the model. It was not the model, and it was not the SSE format either. It was my parser making two calm, reasonable-looking assumptions that the network is under no obligation to honor.
Here is the whole retrospective: the symptom table, the byte capture, the two root causes, and the fuzz test I now keep in the repo so this class of bug cannot come back quietly.
The symptom table I wish I had written first
Before touching code, I wrote down what each layer believed was happening. Three sources of truth disagreed, which is usually a sign that the bug lives between them rather than inside any one of them.
| Client state | What the user sees | What the DOM says | What the network says |
|---|---|---|---|
| Streaming | text grows word by word | text node appended per chunk |
200, chunked, no content-length
|
| Corrupted |
morοΏ½ning mid-word |
a literal U+FFFD in the text node | a chunk boundary inside a 4-byte codepoint |
| Stuck | spinner forever |
aria-busy="true", no terminal message |
final event sitting in my buffer, never dispatched |
Two separate failures were wearing the same costume. The mojibake came from decoding each chunk as if it were a complete document. The stuck spinner came from framing: my parser waited for \n\n while the server politely sent \r\n\r\n, which never matches that pattern.
Notice that neither symptom is visible in a unit test that feeds whole strings into a function. Both require a stream that arrives in pieces, and pieces are exactly what real networks deliver.
Step one: capture bytes, never strings
The fastest way to stop guessing is to look at the wire. curl with -N disables buffering, and xxd shows you where the actual codepoint boundaries fall:
curl -N http://localhost:8787/stream | xxd | head -40
What I saw was unglamorous but decisive: the 4-byte sequence for π
was split across two TCP reads, f0 9f in one chunk and 8c 85 in the next. My decoder received f0 9f, decided that was an invalid document, and substituted a replacement character for the half it could not see.
If you only remember one debugging habit from this post, make it this one: when text is wrong, drop down to bytes before you drop down to blame. Strings are already an interpretation, and interpretations hide the evidence.
Root cause one: decode() is not a stream function by default
The original line looked harmless, which is the defining property of every line that eventually ruins a Friday.
// Before: each chunk treated as a complete, independent document.
const text = new TextDecoder().decode(value);
The { stream: true } option tells the decoder to hold back an incomplete trailing sequence until the next read arrives. Without it, you are asking the decoder to judge a fragment as if the document ended there, and it will faithfully report a broken character.
There is a second half to this fix that I missed on my first attempt. The decoder keeps those held-back bytes in an internal buffer, and if you never flush it, the tail of the stream simply vanishes. Two calls, both required:
buffer += decoder.decode(value, { stream: true }); // mid-stream
// later, after the reader reports done:
buffer += decoder.decode(); // flush the tail
Why does this matter for accessibility and not just cosmetics? Because a replacement character is not a visual glitch in a live region; a screen reader will announce it or stumble over it, and the user has no way to know the text is incomplete rather than strange.
Root cause two: the framing assumption I inherited from a tutorial
My parser split on '\n\n', which is correct for one of the two line endings the SSE specification allows. The server sent CRLF because it was built on a framework that prefers it, and \r\n\r\n does not contain \n\n as a substring.
So every event stayed in the buffer while the reader loop dutifully kept reading, and the terminal data: [DONE] event never reached my state machine. The UI stayed busy forever, with no error, no timeout, and nothing in the console. Silent states are the expensive ones.
A one-line change fixes the framing, and I now treat it as non-negotiable:
const parts = buffer.split(/\r?\n\r?\n/);
While you are in there, strip an optional leading data: space rather than assuming it, and ignore comment lines that start with :. Servers keep connections warm with comments, and a parser that treats one as an event will surprise you at the worst possible moment.
The repaired reader, with a finally that actually runs
Here is the version I shipped. It handles decoder state, CRLF framing, and lock release without ceremony.
export async function readSSE(response, onData) {
if (!response.body) throw new Error('no stream body');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = dispatch(buffer, onData);
}
buffer += decoder.decode();
dispatch(buffer, onData, true);
} finally {
reader.releaseLock();
}
}
function dispatch(buffer, onData, flush = false) {
const parts = buffer.split(/\r?\n\r?\n/);
const rest = flush ? '' : parts.pop() ?? '';
for (const block of parts) {
for (const line of block.split(/\r?\n/)) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trimStart();
if (payload) onData(payload);
}
}
return rest;
}
One subtlety worth naming out loud: releaseLock() in a finally is what lets a cancelled or failed stream be garbage collected promptly. I have written about reused abort controllers before, and the pattern rhymes β stream resources need explicit endings, because nothing else will end them for you.
Reusable technique: split the payload at every byte
Fixing the parser did not prove the fix, so I wrote the smallest test that could contradict me. It slices the same payload at every possible offset and asserts the decoded result never changes.
// parser.test.js β run with: node --test
import test from 'node:test';
import assert from 'node:assert/strict';
const sample = 'data: {"text":"Good morning π
cafΓ©"}\r\n\r\ndata: [DONE]\r\n\r\n';
const bytes = new TextEncoder().encode(sample);
test('decoding is split-agnostic', () => {
for (let cut = 1; cut < bytes.length; cut++) {
const decoder = new TextDecoder('utf-8');
let out = decoder.decode(bytes.subarray(0, cut), { stream: true });
out += decoder.decode(bytes.subarray(cut), { stream: true });
out += decoder.decode();
assert.equal(out, sample, `split at byte ${cut}`);
}
});
test('the naive decoder provably fails', () => {
const cut = bytes.indexOf(0xf0) + 1; // just inside the emoji's lead byte
const decoder = new TextDecoder('utf-8');
const out = decoder.decode(bytes.subarray(0, cut)) + decoder.decode(bytes.subarray(cut));
assert.match(out, /\uFFFD/);
});
The second test matters more than the first. A regression test that passes proves little; a test that reproduces the original failure proves you understood it, and it fails loudly the day someone "simplifies" the decoder call.
To reproduce the network conditions deterministically, I wrote a hostile server that writes one byte at a time, so every multi-byte sequence is guaranteed to be severed:
import { createServer } from 'node:http';
const enc = new TextEncoder();
const body = ['Good morning π
', 'cafΓ© and naΓ―ve', 'ζ₯ζ¬θͺ π§ͺ', 'e\u0301 stays attached']
.map((t) => `data: ${JSON.stringify({ text: t })}\r\n\r\n`)
.join('') + 'data: [DONE]\r\n\r\n';
createServer(async (req, res) => {
if (req.url !== '/stream') return res.writeHead(404).end();
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
for (const b of enc.encode(body)) {
res.write(Buffer.from([b]));
await new Promise((r) => setTimeout(r, 1));
}
res.end();
}).listen(process.env.PORT ?? 8787);
The corpus is deliberately nasty: an emoji, Latin-1 accents, CJK, and a combining acute accent that must stay attached to its base letter. If your parser handles a decomposed e plus U+0301 across a chunk boundary, it will handle almost anything a real model produces.
Where a free server and free models earned their keep
This is the part where a hobby debugging session usually hits a wall: you cannot reproduce a hostile network on localhost alone, and you do not want to point a test loop at production. As of this writing, the MonkeyCode open-source project advertises free model access, including a free token allowance, plus a free server option; check the project's own page for the current terms, because free tiers move and this article should not be your source of record.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I used it in two small, honest ways. First, I deployed the byte-at-a-time endpoint to the free server option, set the start command to node server.js, and read the injected PORT β that gave me a public URL I could open from a phone on mobile data, which is exactly where the bug first appeared. Second, I asked a free model to generate a multilingual corpus of short strings with emoji, RTL text, and combining marks, then pasted the output straight into the fixture array above.
That is the entire role it played. It did not find the bug, and it did not fix the parser; it removed the friction of having somewhere unglamorous to run a deliberately broken server. If you already have a staging environment and a fixture generator, you need none of this.
Accessibility follow-through, which was not optional
My first fix stopped the corruption, but the retry still felt wrong to a screen reader user on the team. Here is what changed after she tested it.
- Announce per completed sentence, not per chunk, so assistive tech never reads half a word and then revises itself.
- Never insert U+FFFD into a live region. If decoding fails, drop the fragment and let the terminal error state speak.
- Always reach a terminal state, success or failure, so
aria-busycannot staytruewhile the user waits for a message that will never arrive. - Keep a visible, focusable status line with the message count, so nobody has to infer completion from silence.
None of those are ARIA tricks. They are consequences of the state machine actually being finite, which is what the framing bug had quietly violated.
Limitations, and who should not use this setup
A free server is a debugging convenience, not infrastructure. It may sleep, cold-start, or rate-limit, so do not put a demo behind it and promise an SLA; do not send customer data, credentials, or anything regulated through a free hosted endpoint; and do not treat free model access as a stable, pinned version you can benchmark against.
If your product needs reproducible model output, contractual uptime, or a data-processing agreement, run the hostile server locally and use your own approved provider. The fuzz test and the parser fix are completely independent of any hosting choice, and they are the parts worth keeping.
What I would like from you
If you have a stream that renders a character that should not exist, my bet is now firmly on a chunk boundary rather than a model. Try the split-at-every-byte test against your own parser this week, and it will either pass in seconds or hand you a bug you would otherwise have met in production.
If it fails in a way I have not covered, please reply with your browser and OS versions, the assistive technology if any, and the exact byte offset where the split happened. A reproduction beats a hypothesis every time, and I would rather debug your boundary than guess at mine.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.