ReadingNetwork Chunks Corrupted My Text and Crashed My JSON: Buffer Before You Parse
7 min read

Network Chunks Corrupted My Text and Crashed My JSON: Buffer Before You Parse

The network splits a 4-byte emoji across two reads, and each half decodes as garbage.

Four steps with one emoji: its four bytes, the cut between chunk 1 and chunk 2, the broken decode, and the fix. Decoded chunk by chunk, each half turns into U+FFFD replacement marks. The fixed decoder holds F0 9F until the rest arrive and prints the emoji once.A rocket emoji is 4 bytes, split by a dashed line: F0 9F in chunk 1, 9A 80 in chunk 2. A cross marks Decode each chunk alone: one replacement mark under chunk 1, two under chunk 2, and three on screen. A check marks Keep leftover bytes: the decoder holds F0 9F, an arrow carries them to chunk 2, and a rocket shows there and on screen. split 🚀 = 4 bytes F0 9F 9A 80 chunk 1 chunk 2 screen Decode each chunk alone � �� ��� Keep leftover bytes holds F0 9F 🚀 🚀 Four steps with one emoji: its four bytes, the cut between chunk 1 and chunk 2, the broken decode, and the fix. Decoded chunk by chunk, each half turns into U+FFFD replacement marks. The fixed decoder holds F0 9F until the rest arrive and prints the emoji once.A rocket emoji is 4 bytes, split by a dashed line: F0 9F in chunk 1, 9A 80 in chunk 2. A cross marks Decode each chunk alone: one replacement mark under chunk 1, two under chunk 2, and three on screen. A check marks Keep leftover bytes: the decoder holds F0 9F, an arrow carries them to chunk 2, and a rocket shows there and on screen. 🚀 = 4 bytes split F0 9F 9A 80 chunk 1 chunk 2 Decode each chunk alone � �� screen ��� Keep leftover bytes holds F0 9F 🚀 screen 🚀

One emoji is four bytes

Step 1: the rocket emoji is four bytes in UTF-8, F0 9F 9A 80, and together they are one character. in UTF-8 🚀 = F0 9F 9A 80 one character, four bytes

The network cuts after byte two

Step 2: the network cuts after the second byte. F0 9F arrive in chunk 1, 9A 80 in chunk 2. 🚀 = F0 9F 9A 80 cut chunk 1 chunk 2 arrives first arrives next

Decode each chunk alone, get garbage

Step 3: decoding each chunk alone gives one replacement mark for chunk 1 and two for chunk 2, so three reach the screen. 🚀 = F0 9F 9A 80 chunk 1 chunk 2 screen Decode each chunk alone � �� ��� cut

Keep the leftover bytes, get the emoji

Step 4: the fixed decoder holds F0 9F, joins them with 9A 80, and one rocket reaches the screen. 🚀 = F0 9F 9A 80 chunk 1 chunk 2 screen Keep leftover bytes holds F0 9F 🚀 🚀 cut
1 of 4
Figure 1. Four steps with one emoji: its four bytes, the cut between chunk 1 and chunk 2, the broken decode, and the fix. Decoded chunk by chunk, each half turns into U+FFFD replacement marks. The fixed decoder holds F0 9F until the rest arrive and prints the emoji once.

I put a stateful reassembler in front of the parser: a Node.js Transform stream that holds partial UTF-8 bytes and half-received SSE events until they are complete, and only then decodes and parses them.

Listen to the audio overview(2:27)Fenrir Studio Voice
0:00
2:27

I streamed a model's reply into my chat UI. Some characters came out as garbled diamonds: � (U+FFFD The replacement character, shown as �. A decoder prints it when bytes do not form a valid character.). The reply arrived as SSE Server-Sent Events. A server keeps one reply open and sends text events, each ended by a blank line. events over a TCP The protocol that carries bytes between two machines in order. It may split or join them anywhere. connection. The network cut it into Chunk One piece of a reply as it arrives from the network. Its edges have nothing to do with characters., and sometimes a cut landed in the middle of a character. My code decoded each chunk by itself, so both halves of that character broke.

Text on the wire is Byte The smallest unit a computer stores or sends. This essay writes each one as two characters, such as F0.. In UTF-8 The usual way to store text as bytes. Each character takes one to four bytes., a plain letter takes one byte and an emoji like 🚀 takes four. Chinese, Japanese and Korean characters take more than one byte, too. A chunk can end between any two bytes. My code ran TextDecoder The standard API that turns bytes into text. Unless told a stream continues, it decodes each call alone. on each chunk alone. A decoder that sees half a character renders it as a replacement diamond in my UI.

I built the simulator below to show the failure in bytes. It opens on the same 🚀, cut after its second byte. Type your own text: only characters of two or more bytes, such as emoji and accented letters, can tear. Then shrink the chunk size, watch characters tear at the cuts, and see them heal through my streaming decoder:

Interactive Lab · stream-tearBroken emoji in your chat UI

Type your own text, shrink the chunk size, and watch multibyte characters tear at a chunk boundary, then heal with streaming decode.

Beginner tutorials assume a network call returns at once, with tidy text. In production I found that network packets ignore character and JSON A common text format for structured data. A parser needs the whole object; half of one fails. boundaries. So a reliable stream needs a reassembler in Node.js. It keeps partial bytes until a character is whole, and partial text until an event is whole. Only then does it decode and parse.

The leaky reality: In my production AI streams, network packets do not line up with characters or JSON objects. If I treat each incoming piece as a complete string, I corrupt Unicode text and crash on half a JSON object.
PART 01

Why the stream breaks, and when I need the fix

01

The failure anatomy: a character or a JSON object cut in two

I stream model output with Server-Sent Events (SSE) or HTTP chunked transfer encoding. Either way, my runtime receives raw bytes, as Buffer or Uint8Array chunks. A critical mistake is to treat each chunk as a complete token. A chunk is only whatever the network delivered next.

A. A character cut in two. UTF-8 characters (IETF RFC 3629) take 1 to 4 bytes: ğ is 2 bytes and 🚀 is 4 bytes. TCP packet boundaries (IETF RFC 9293) can fall inside those bytes. Then chunk.toString('utf-8') decodes a partial character and produces \uFFFD, the Unicode replacement character. That character is lost for good in my output stream.

B. A JSON object cut in two. In structured output and tool-calling modes, models send JSON frames inside SSE events. One JSON object often spans several network chunks. If I call JSON.parse(chunk) on one of them, it throws at once: SyntaxError: Unexpected end of JSON input.

Before I write a backend stream parser, I check whether my architecture needs one at all. I hit chunk boundary crashes when I deployed backends, for two different reasons:

02

Hiding the API key (the anti-pattern)

I often see engineers proxy model calls through a Cloud Function only to hide the API key from the client. When hiding the key is the only goal, a backend proxy is an anti-pattern. It adds latency, compute cost and stream parsing work.

My client-side pattern: I use a client SDK such as the Firebase AI Logic client SDK with App Check. The API key stays out of my client code. My client app calls Gemini models directly and securely, and the SDK puts the chunks back together for me.
03

Trusted backend execution (the mandatory pattern)

Some of my agent logic cannot live on the client. One example is a workflow that runs RAG Retrieval-Augmented Generation. My code looks up documents first and gives them to the model with the question. against a private vector database. Others are tool calls against third-party endpoints, and proprietary reasoning loops I must protect.

My trusted backend pattern: I route the request through a trusted environment such as Cloud Functions for Firebase (Gen 2) or Firebase App Hosting.

In this second case, my Node.js code parses the raw HTTP chunks itself. Then it streams them back to the client. Here I must manage the bytes explicitly, and this is where my parser lives.

PART 02

The old way vs. the new way

This table sets my naive parser beside my production reassembler. The reassembler is stateful: it remembers leftover bytes and text from one chunk to the next.

Failure modeThe old way (naive parser)The new way (stateful reassembler)
A character cut in two UTF-8 byte splitDecodes each chunk alone. chunk.toString('utf-8') corrupts multi-byte sequences into \uFFFD.Holds the leftover bytes. StringDecoder A Node.js decoder that keeps the bytes of an unfinished character and joins them to the next chunk. holds incomplete bytes in my internal buffer until complete.
A JSON object cut in two Split JSON payloadsParses half an object. JSON.parse(rawString) crashes my runtime on partial frames.Waits for the blank line. Delimiter-based line buffering isolates complete data: blocks before parsing.
Two events in one chunk Fused SSE packetsKeeps the first event, drops the rest. Processes first payload, dropping trailing events in the same chunk.Handles every event in the chunk. Loops through every blank line (two line endings in a row, each \n, \r\n or \r) within my combined buffer.
One malformed event Error recoveryOne bad event ends the stream. Stream crashes, terminating my client connection abruptly.Flags the bad event and keeps going. Marks a malformed JSON frame with a parseError field and keeps the underlying transport alive.

Bytes become text

Step 1: chunk 1 brings F0 9F and chunk 2 brings 9A 80. The byte decoder holds the split bytes and passes on one whole character. chunk 1 F0 9F chunk 2 9A 80 Byte decoder holds split bytes text 🚀 one character

Text becomes events

Step 2: the text of one event arrives in two chunks. The event buffer waits for the blank line and passes on one whole event. chunk 1 data: {"a": chunk 2 1}\n\n \n\n = blank line Event buffer waits for a blank line {"a":1} one event

Only whole events reach JSON.parse

Step 3: parsing a piece of an event throws a SyntaxError and crashes the stream. Parsing only whole events gives one object, and the stream goes on. Parse each chunk {"a": SyntaxError, stream crashes Parse whole events {"a":1} one object, stream goes on
1 of 3
Figure 2. Three layers in order, and the first two each hold back one kind of partial piece. The byte decoder holds a split character's bytes. The event buffer holds text until a blank line (\n\n) closes the event. Only then does JSON.parse run, once per whole event; parsing a piece of an event throws and crashes the stream.
  • Byte decoder. It takes raw Buffer or Uint8Array network chunks. When a TCP packet split cuts a UTF-8 character, it keeps the incomplete bytes in memory. Then it joins them to the next chunk.
  • Event buffer. It collects decoded text until a blank line closes the event. Then it cuts off the complete events and keeps the partial tail. A blank line is two line endings in a row, each \n, \r\n or \r, so mixed endings split too. A tail still open when the stream ends is reported as a torn event after the complete ones, never parsed. A trailing comment line does not count as a tail.
  • Safe JSON parsing. It runs JSON parsing on each whole event by itself and keeps the exact text in raw. A plain-text token like 1.50 comes out of data as the number 1.5, so a text stream reads raw. A broken object or array carries a parseError instead of passing as text, and my stream keeps running.
leaky-vol1-utf8-tearScreen recording
Terminal Proof: A 22-Byte Payload Split Mid-Emoji Yields 3 U+FFFD Replacement Characters
Show the shell command (reproduce locally)
node utf8_tear.js
PART 03

Production-ready TypeScript implementation

Below is my production-tested SafeSseReassemblyStream class. I deploy it as a Node.js Transform stream on Cloud Functions or Firebase App Hosting endpoints. It keeps leftover bytes in a StringDecoder and leftover text in a string buffer:

streaming-buffer-reassembler.ts
TypeScript
// Resilient SSE & UTF-8 Stream Reassembler for Node.js / Cloud Run
import { StringDecoder } from 'node:string_decoder';
import { Transform, type TransformCallback } from 'node:stream';

export interface SseEvent<T = unknown> {
  event?: string;
  data: T;
  id?: string;
  retry?: number;
  parseError?: string; // set when an object or array payload fails JSON.parse
  raw: string; // the data lines exactly as sent, for plain-text token streams
}

// SSE ends each event with a blank line: two line endings in a row, each LF, CRLF or CR.
// A CR counts as a line ending on its own only when no LF follows it.
const EVENT_BOUNDARY = /(?:\r\n|\r(?!\n)|\n){2}/;
const MAX_BUFFER_CHARS = 1_000_000;

export class SafeSseReassemblyStream extends Transform {
  private readonly decoder: StringDecoder;
  private buffer: string;

  constructor() {
    super({ readableObjectMode: true });
    this.decoder = new StringDecoder('utf8');
    this.buffer = '';
  }

  public _transform(
    chunk: Buffer | Uint8Array | string, 
    _encoding: string, 
    callback: TransformCallback
  ): void {
    try {
      const text = typeof chunk === 'string' 
        ? chunk 
        : this.decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
      
      this.buffer += text;

      let match: RegExpExecArray | null;
      while ((match = EVENT_BOUNDARY.exec(this.buffer)) !== null) {
        const rawEvent = this.buffer.slice(0, match.index);
        this.buffer = this.buffer.slice(match.index + match[0].length);

        const parsed = this.parseSseBlock(rawEvent);
        if (parsed) {
          this.push(parsed);
        }
      }

      if (this.buffer.length > MAX_BUFFER_CHARS) {
        throw new Error('SSE event passed MAX_BUFFER_CHARS without a closing blank line');
      }

      callback();
    } catch (error) {
      callback(error instanceof Error ? error : new Error(String(error)));
    }
  }

  public _flush(callback: TransformCallback): void {
    const leftover = this.buffer + this.decoder.end();
    this.buffer = '';
    // An event with no closing blank line is torn: never parse it, report it in-band.
    // callback(err) here would destroy the stream and drop events the reader has not taken yet.
    if (leftover.split(/\r\n|\r|\n/).some((line) => line.trim() !== '' && !line.startsWith(':'))) {
      this.push({ event: 'torn', data: null, raw: '', parseError: 'Stream ended mid-event; partial frame discarded' });
    }
    callback();
  }

  private parseSseBlock(rawBlock: string): SseEvent | null {
    let eventType: string | undefined;
    let id: string | undefined;
    let retry: number | undefined;
    const dataLines: string[] = [];

    for (const line of rawBlock.split(/\r\n|\r|\n/)) {
      if (line.startsWith(':')) continue; // comment or heartbeat

      // A line with no colon is a field with an empty value, e.g. a bare "data".
      const colonIdx = line.indexOf(':');
      const field = colonIdx === -1 ? line : line.slice(0, colonIdx);
      const value = colonIdx === -1 ? '' : line.slice(colonIdx + 1).replace(/^ /, '');

      switch (field) {
        case 'data': dataLines.push(value); break;
        case 'event': eventType = value; break;
        case 'id': id = value; break;
        case 'retry': if (/^\d+$/.test(value)) retry = Number(value); break;
      }
    }

    if (dataLines.length === 0) return null;

    const rawData = dataLines.join('\n');
    let data: unknown = rawData;
    let parseError: string | undefined;

    try {
      data = JSON.parse(rawData);
    } catch (error) {
      // Plain-text tokens stay strings; a broken object or array is flagged, not hidden.
      if (/^\s*[{[]/.test(rawData)) parseError = String(error);
    }

    return { event: eventType, data, raw: rawData, id, retry, parseError };
  }
}
Architectural takeaway: I never assume network chunks line up with character or JSON boundaries. I keep raw bytes across TCP boundaries with StringDecoder. I also keep transport delimiters apart from application payloads. A partial event never reaches my parser, so it never crashes my stream in production.
Architecture blueprint and spec: Inspect my complete Deterministic Agent Runtime Blueprint → or test my streaming token burn with the AI Tokenomics Solvency Calculator →

Industry validation and benchmarks

First published 10 Aug 2026 · last revised 7 Oct 2026 · 36 revisions

Hand this to your agent

You

Read this essay by Ibrahim Ulukaya, https://ulukaya.dev/posts/the-leaky-abstraction-vol1, and look at my project. Where does my stream parser fail to buffer partial UTF-8 bytes and half-received SSE events across network chunks before it decodes and parses them? Name the file and line for each finding.

Cited by
  1. 200 Tokens a Second Locked Up My Browser: Client-Side Defense for Agent Streams

    ……one React update per token My first frontend bound the raw streaming chunk handler straight to React state: AntiPattern.tsx TypeScript //……

  2. Instrument: Broken emoji in your chat UI

    Type your own text, shrink the chunk size, and watch multibyte characters tear at a chunk boundary, then heal with streaming decode.