Skip to content

JavaScript UTF-8 Byte Limits: Keep Complete Grapheme Clusters

To shorten text to a UTF-8 byte budget, add complete grapheme clusters until the next one would exceed it. TextEncoder measures the bytes; Intl.Segmenter supplies the boundaries. Returning a slice of the original string preserves the retained prefix.

This approach fits a rule that allows shortening the selected text. If your application must preserve the entire value, reject an oversized value instead. A byte budget is separate from a JavaScript string-length or visible-character limit; see the String Length guide for those units.

Build the prefix from whole graphemes

The helper below requires TextEncoder, Intl.Segmenter and String.prototype.isWellFormed in the target runtime. Check compatibility before using it in older environments. It accepts a nonnegative safe integer budget and rejects malformed strings containing lone surrogates, as detected by isWellFormed().

function prefixWithinUtf8(text, maxBytes) {
  if (typeof text !== "string" || !text.isWellFormed()) {
    throw new TypeError("Expected well-formed text");
  }
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
    throw new RangeError("Expected a nonnegative byte budget");
  }
  const encoder = new TextEncoder();
  const segmenter = new Intl.Segmenter("en", {
    granularity: "grapheme"
  });
  let bytes = 0;
  let end = 0;
  for (const { segment, index } of segmenter.segment(text)) {
    const size = encoder.encode(segment).length;
    if (bytes + size > maxBytes) break;
    bytes += size;
    end = index + segment.length;
  }
  return { text: text.slice(0, end), bytes };
}

TextEncoder.encode() returns UTF-8 bytes. The grapheme option chooses cluster boundaries, while each segment record supplies its original index. Adding the segment’s UTF-16 length gives a slice endpoint at that boundary.

The loop stops at the first cluster that cannot fit. Skipping that cluster and adding later text would no longer produce an original prefix. Empty input or a budget smaller than the first cluster returns an empty string with zero bytes.

Expect some unused bytes

For the exact input A👩‍💻é, the graphemes occupy one, eleven and two UTF-8 bytes in the checked Chrome runtime. The emoji contains U+1F469, U+200D and U+1F4BB. These synthetic examples run the helper above:

BudgetReturned prefixUTF-8 bytes
1A1
11A1
12A👩‍💻12
14A👩‍💻é14

At a budget of eleven bytes, only A fits: adding the complete emoji would require twelve bytes in total. A budget of thirteen returns the same prefix as twelve. The result is the longest prefix at the chosen grapheme boundaries, even when some capacity remains unused.

Preserve the text and measure the right scope

Precomposed é (U+00E9) needs two UTF-8 bytes. The similar-looking sequence e followed by U+0301 needs three and remains one grapheme in this example. With a two-byte budget, the helper keeps the former and returns an empty prefix for the latter. It does not apply Unicode normalization; changing the input first would change what is being preserved.

The returned byte count covers only the selected text encoded as UTF-8, without an added byte-order mark. It excludes JSON quotes, escapes, property names and transport overhead. If the receiving limit covers serialized JSON, measure that representation separately using the JSON length guide. This helper establishes no receiving platform’s limit.

Keep the target runtime in your checks: its segmentation rules determine the boundaries, and a grapheme boundary is not a promise of identical rendering everywhere. The helper encodes each segment separately, so assess larger inputs in your application before choosing it for a large-file or streaming task.