Article

LLM Unicode Output Governance in Production: Incremental UTF-8 Decoding, Grapheme Clusters, and NFC Boundary Buffering to Prevent Garbled Text and Truncation

A systematic guide for streaming LLM applications covering three layers of boundaries—network bytes, API deltas, and user-visible characters—with solutions for incremental UTF-8 decoding, extended grapheme cluster buffering, NFC normalization, and replay testing to avoid garbled text, emoji splitting, and index mismatches.

LLM Unicode Output Governance in Production: Incremental UTF-8 Decoding, Grapheme Clusters, and NFC Boundary Buffering to Prevent Garbled Text and Truncation

Background: Garbled Text Is Usually Not a Model Error

Streaming LLM applications often assume that “receiving a delta” means “receiving a piece of text that can be immediately and stably displayed.” This assumption holds only for pure ASCII. Once you enter the world of multilingual text, emoji, combining diacritical marks, and complex writing systems, the output pipeline faces at least three types of boundaries:

  1. Network byte boundaries: TCP, HTTP, SSE, or WebSocket fragments can split a UTF-8 multi-byte sequence in the middle.
  2. API delta boundaries: The string delivered by the server may end in the middle of a combining mark, variation selector, or ZWJ emoji sequence.
  3. User-visible character boundaries: What a user perceives as a single “character” is often an Extended Grapheme Cluster composed of multiple Unicode code points.

Consequently, bytes.length, the number of code points, JavaScript’s string.length, the model’s token count, and the number of characters a user sees are not the same metric. Mixing these concepts leads to common issues:

  • Occasional replacement characters ;
  • Emoji initially appearing as two icons, then merging or jumping;
  • Accented characters, South Asian scripts, and Hangul combinations briefly misaligned during incremental rendering;
  • Inconsistent cursor, truncation, sensitive word highlighting, and offset positioning;
  • Cache keys, database unique keys, or content hashes producing different results for visually identical text.

The focus of governance is not to add a try/catch on the frontend, but to build a layered state machine from bytes to final text.

Core Principles: First Distinguish Four Units of Measurement

UTF-8 Bytes

UTF-8 uses one to four bytes to encode a single Unicode scalar value. Network fragmentation can occur at any byte position, so you cannot perform stateless decoding on each binary chunk independently. The WHATWG Encoding Standard specifies a streaming decoding approach for fragmented input: maintain the decoder state across successive calls, and perform a final flush at the end.

Unicode Code Point

A code point is a Unicode number, e.g., U+0065. But the é a user sees could be either a single U+00E9 or U+0065 followed by U+0301. Both are visually equivalent but have different binary representations.

UTF-16 Code Unit

Browser and JavaScript string indices typically count UTF-16 code units. A supplementary plane character occupies two code units, so text.length does not directly represent the number of code points, let alone the number of user-visible characters.

Extended Grapheme Cluster

Unicode UAX #29 defines the extended grapheme cluster as the default boundary that more closely matches a user’s perception of a character. Combining diacritical marks, emoji skin tone modifiers, flag regional indicators, and ZWJ emoji sequences all require grapheme cluster boundaries as the basic unit for display, deletion, truncation, and cursor movement.

The following table summarizes the differences between these four units:

Unit of MeasurementDefinitionTypical Use CaseCommon Misconception
UTF-8 ByteOne to four bytes encode one code pointNetwork transmission, storage capacity, hashingUsing byte offsets for UI truncation
Code PointUnicode scalar value (U+0000–U+10FFFF)Character property lookup, case conversionTreating it as “the character the user sees”
UTF-16 Code UnitThe value of JavaScript string.lengthBrowser DOM indexingRepresenting the number of characters
Extended Grapheme ClusterA user-perceived “single character”Display, cursor, truncation, deletionUsing a custom regex instead of the full segmentation algorithm

Production Pipeline: A Five-Layer State Machine

Layer 1: Byte Streams Only Enter an Incremental UTF-8 Decoder

On the browser side, use a stateful TextDecoder:

const decoder = new TextDecoder("utf-8", { fatal: true });

async function consume(source: AsyncIterable<Uint8Array>) {
  for await (const bytes of source) {
    const text = decoder.decode(bytes, { stream: true });
    if (text) acceptDecodedDelta(text);
  }
  const tail = decoder.decode(); // flush end-of-stream state
  if (tail) acceptDecodedDelta(tail);
}

In production, use strict decoding within the protocol. When encountering illegal bytes, log the error and terminate the current response, rather than silently replacing them with and continuing to persist. Replacement mode is suitable for best-effort display, but not as an authoritative source for auditing, cache keys, or business data.

If the SDK already delivers complete strings instead of raw bytes, incremental UTF-8 decoding should be handled by the SDK or transport layer; the business layer should not re-encode and decode.

Layer 2: Deltas Represent Transmission Increments, Not Character Commit Points

Even if every delta is a valid Unicode string, boundaries can still fall within the following sequences:

  • e followed by a combining accent;
  • Emoji followed by a skin tone modifier;
  • Emoji, ZWJ, and a subsequent emoji;
  • Two Regional Indicators forming a flag;
  • Devanagari, Tamil, and other scripts’ consonants, viramas, and vowel signs;
  • Hangul Jamo combinations.

Therefore, after receiving a delta, it should first be appended to a text assembler, and then the grapheme boundary layer determines which content can be stably committed.

Layer 3: Retain the Last Unconfirmed Grapheme Cluster

A practical strategy: each time, concatenate the new delta with pendingTail, then re-segment by extended grapheme clusters. All clusters except the last can be committed; the last one remains in the buffer until more content is received or the stream ends.

class GraphemeCommitter {
  private pendingTail = "";
  private readonly segmenter = new Intl.Segmenter("und", {
    granularity: "grapheme",
  });

  push(delta: string, final = false): string {
    this.pendingTail += delta;
    const parts = [...this.segmenter.segment(this.pendingTail)].map(
      (item) => item.segment
    );

    if (final) {
      const output = parts.join("");
      this.pendingTail = "";
      return output;
    }

    if (parts.length <= 1) return "";

    const stable = parts.slice(0, -1).join("");
    this.pendingTail = parts.at(-1) ?? "";
    return stable;
  }
}

This code is suitable for explaining the boundary strategy, but before going live, you must also pin the runtime and Unicode/ICU version, and use the official Unicode Grapheme Break test suite for regression. Do not replace the full segmentation algorithm with a custom “is it a combining character” check or a few emoji regexes.

Layer 4: Normalization Must Account for Concatenation Boundaries

Unicode UAX #15 defines NFC, NFD, NFKC, and NFKD. For chat output that preserves the original text semantics, NFC is the default choice: it unifies canonically equivalent combining forms without aggressively collapsing compatibility characters like NFKC does.

The key pitfall is that normalization forms are not closed under string concatenation. Performing normalize("NFC") on each delta individually and then concatenating the results does not guarantee that the final string is still NFC. In engineering, adopt a two-tier strategy:

  • For real-time display, only commit complete grapheme clusters; avoid making final judgments on unstable tails.
  • After the stream ends, perform NFC once on the complete message, and use this result as the authoritative text for persistence, comparison, search indexing, and hashing.
  • If you must persist while generating, keep an overwritable tail window; only commit an immutable prefix after it crosses a stable boundary.

Do not default to NFKC on code snippets, mathematical expressions, usernames, or external business IDs. Compatibility normalization may collapse fullwidth, circled, superscript, or specific symbols into another representation.

Layer 5: The Final Event Is for Reconciliation, Not Just Closing the Connection

When the stream ends, perform a complete Finalize:

  1. Flush the UTF-8 decoder.
  2. Flush pendingTail.
  3. Perform NFC on the full text.
  4. Recalculate byte count, code point count, grapheme count, and token count.
  5. Reconcile the final text with the incrementally assembled result.
  6. Generate a content hash and persist it.
  7. Run final parsing for Markdown, code blocks, highlighting, or sensitive information processing.

If the vendor also provides the final complete text, prioritize it as the authoritative result, compare it with the locally assembled delta value, and log any discrepancies—do not unconditionally overwrite and lose diagnostic evidence.

Data Model: Don’t Just Store a Single length

Recommend recording different semantic counts for streaming messages:

{
  "utf8_bytes": 1842,
  "unicode_code_points": 917,
  "utf16_code_units": 944,
  "grapheme_clusters": 861,
  "model_tokens": 532,
  "normalization_form": "NFC",
  "unicode_runtime_version": "pinned-by-runtime",
  "assembly_hash": "sha256:..."
}

These fields don’t all need to be retained long-term, but during troubleshooting, you must be able to distinguish between “model token limit exceeded,” “UI character truncation,” “UTF-16 offset error,” and “hash changed after normalization.”

For annotations, highlighting, and audit hit positions, it is recommended to define a clear Offset Contract:

Offset TypeUse CaseExample
UTF-8 Byte OffsetNetwork layer positioning, storage shardingbyte_offset
UTF-16 Code UnitJavaScript DOM indexingjs_offset
Code Point IndexUnicode property processingcp_index
Grapheme IndexUser interface highlighting, truncationgrapheme_index

In cross-service protocols, do not just write start and end.

Engineering Implementation: Build Tests Around Boundaries, Not Language-Specific Examples

Byte Fragment Replay

For the same UTF-8 data, split it at every possible byte position and feed each chunk into the incremental decoder. All split methods should produce the same final text. Also cover:

  • Incomplete bytes remaining at the end of the stream.
  • Illegal continuation bytes.
  • BOM.
  • Both strict and replacement error strategies.

Delta Fragment Replay

Split the same Unicode string at every code point boundary, and verify that any combination of deltas produces the same final NFC text. Key samples should include:

  • Precomposed characters vs. base + combining mark.
  • Emoji skin tones, variation selectors, and ZWJ family sequences.
  • Flag sequences.
  • Hangul Jamo.
  • Indic conjuncts.
  • Right-to-left text and bidirectional control characters.

Official Conformance Tests

Include Unicode’s NormalizationTest.txt and GraphemeBreakTest.txt in your CI. Whenever you upgrade the JDK, Node.js, browser engine, Python, ICU, or OS image, re-run these tests, as the Unicode data version carried by the runtime may change.

UI Behavior Tests

Automated tests should not only assert the final string, but also check for the absence of the following during the process:

  • ;
  • Half an emoji or temporary splitting;
  • Cursor landing inside a grapheme cluster;
  • Truncation leaving an isolated combining character;
  • Markdown parser frequently re-rendering due to intermediate code fences.

In the real-time UI, the last pending grapheme can be rendered separately as an “uncommitted tail” to avoid full DOM reconstruction.

Observability: Log Error Types, Not Full Original Text

It is recommended to provide at least the following metrics:

Metric NameMeaning
llm_utf8_decode_error_totalIllegal or incomplete UTF-8 sequences
llm_grapheme_pending_sizeDistribution of uncommitted tail lengths
llm_normalization_changed_totalNumber of responses that changed after final NFC
llm_stream_final_mismatch_totalDiscrepancies between local delta assembly and server final value
llm_offset_contract_violation_totalOffsets out of bounds or landing inside a grapheme cluster
llm_unicode_finalize_latency_msLatency of final normalization and reconciliation

By default, logs should save the event type, a summary of Unicode code points, length, and content hash. Do not retain complete sensitive output long-term for troubleshooting garbled text.

Applicable Scenarios

This approach is especially suitable for:

  • Multilingual chat, translation, and customer service systems.
  • Real-time subtitles, LLM polishing after speech-to-text.
  • Products that generate emoji, mathematical symbols, or code.
  • Applications requiring character-level annotation, redaction, highlighting, and audit positioning.
  • Systems where the same text flows between browsers, Java, Python, databases, and search engines.

Pure ASCII internal tools may rarely trigger issues, but as long as the output reaches an external customer interface, you should not rely on “we haven’t seen garbled text yet” as a design basis.

Common Misconceptions

Misconception 1: SSE Events Are Naturally Character Boundaries

SSE only defines the event transmission format; it does not guarantee that a business delta ends at an extended grapheme cluster boundary. The underlying HTTP byte stream can even split in the middle of a UTF-8 byte sequence.

Misconception 2: JavaScript’s string.length Is the Character Count

It counts UTF-16 code units. Emoji, supplementary plane characters, and combining sequences all cause it to deviate from the user-visible character count.

Misconception 3: Performing NFC on Each Delta Is Safe

Individually normalized fragments may not remain in the same normalization form when concatenated. The final message must be re-normalized, and boundary context must be preserved during streaming.

Misconception 4: NFKC Is More Thorough, So It’s Better

NFKC eliminates compatibility differences, which is useful for some search and identifier scenarios, but it can alter code, mathematical symbols, typographic characters, and business IDs. It is not a universal sanitization switch.

Misconception 5: Truncating by Token Is Equivalent to Safe Character Truncation

Model token boundaries serve tokenization and billing; they do not align with Unicode grapheme boundaries. Display truncation must be performed by extended grapheme cluster, while model budget is still calculated by token.

Pre-Launch Checklist

  • Raw bytes use a stateful UTF-8 Decoder, with a flush at EOF.
  • Strictly distinguish between network chunks, API deltas, and user-visible characters.
  • Incremental rendering commits stable prefixes by Extended Grapheme Cluster.
  • After the stream ends, perform NFC on the complete message and generate a final hash.
  • NFKC is enabled only for fields with explicit semantic constraints.
  • All Offset APIs specify the unit and Unicode version.
  • Truncation, deletion, cursor, and highlighting do not enter inside a grapheme cluster.
  • CI includes the official Unicode Normalization and Grapheme Break test suites.
  • Coverage includes emoji ZWJ, flags, combining accents, Hangul, and Indic samples.
  • Log assembly inconsistencies and decode error metrics; do not log complete sensitive output by default.
  • Before upgrading the runtime or ICU, run multilingual replay and canary validation.

FAQ

Why can each delta print normally, but concatenation still leads to comparison failures?

Because visually identical Unicode text can have different code point sequences, and normalization results can change at fragment concatenation boundaries. Printing normally only proves the renderer can display it, not that the binary representation, indices, and hashes are consistent.

If I only do NFC before final persistence, can I skip grapheme cluster buffering?

No. Final NFC solves consistency for persistence and comparison, but it does not prevent emoji splitting, combining character jitter, incorrect truncation, and cursor misalignment in the streaming UI. The two address different stages.

If the server already returns a JSON string, do I still need incremental UTF-8 decoding?

It depends on the abstraction layer. If the SDK has reliably parsed the network bytes into a string, the business layer should not re-decode. If you are directly consuming Fetch, Socket, or proxy-forwarded binary chunks, you must use a stateful decoder. In either case, string deltas still require grapheme boundary governance.

References

  1. Unicode Standard Annex #15: Unicode Normalization Forms
  2. Unicode Standard Annex #29: Unicode Text Segmentation
  3. WHATWG Encoding Standard
  4. Python Codec Registry and IncrementalDecoder
  5. Unicode Normalization Conformance Test
  6. Unicode Grapheme Break Conformance Test

FAQ

Why can the UI still show half an emoji even when every streaming delta is a valid string?
A valid Unicode string can still end in the middle of an extended grapheme cluster. For example, a base emoji, skin tone modifier, and ZWJ sequence can be spread across multiple deltas. The rendering layer must hold the last unconfirmed grapheme cluster and only commit it after receiving subsequent deltas or when the stream ends.
Should streaming output use NFC or NFKC?
NFC is generally preferred for display and persistence. NFKC collapses compatibility characters like fullwidth, circled, and superscript forms, which can alter code, mathematical expressions, or business identifiers. Use NFKC only when there is an explicit need for search or identifier normalization.
Why can't I normalize each delta individually and then concatenate them?
Unicode normalization forms are not closed under string concatenation. Two individually normalized fragments may not produce a normalized result when concatenated. Therefore, you must maintain a boundary buffer and re-normalize the entire string before persistence, comparison, or hashing.