# Unicode Strings in JavaScript: 7 Bugs That Break i18n

> Broken emoji in truncated text, names rejected by regex, accents that defeat search, and lists sorted wrong. The Unicode string bugs every internationalized JavaScript app hits, and how to fix each with Intl.Segmenter, normalize(), and Intl.Collator.
- **Author**: Mohit Sapat
- **Published**: 2026-10-05
- **URL**: https://www.babelize.co/blogs/unicode-string-handling-javascript-i18n

---

<p class="lead">
    Your translations are perfect, and your app still breaks in other languages: a name field rejects "José", a truncated notification ends in a broken emoji, a search for "resume" misses "Résumé", and a sorted list puts "Äpfel" after "Zebra." These are string-handling bugs, not translation bugs. This guide covers the seven Unicode mistakes that show up in nearly every JavaScript codebase that goes international, with the fix for each.
</p>

<h2>The One Fact Behind Most of These Bugs</h2>

<p>
    JavaScript strings are sequences of UTF-16 code units, and <code>.length</code> counts code units. But what a user sees as one character, called a <em>grapheme cluster</em>, can be made of several code points, and each code point can take one or two code units.
</p>

<pre><code>"a".length;            // 1
"👍🏽".length;           // 4  (thumbs up + skin tone, 2 code units each)
[..."👍🏽"].length;      // 2  (code points)
"👨👩👧".length;          // 8  (man, ZWJ, woman, ZWJ, girl)
[..."👨👩👧"].length;     // 5
"नमस्ते".length;          // 6  (Hindi "namaste": 3 visible clusters)</code></pre>

<p>
    Three different units, three different answers. Spreading a string into an array (<code>[...str]</code>) fixes the surrogate-pair problem but still splits emoji sequences and combining marks. For what users perceive as characters, you need <code>Intl.Segmenter</code>.
</p>

<h2>Bug 1: Counting and Truncating Characters</h2>

<p>
    <code>str.slice(0, n)</code> cuts at a code-unit boundary. When that boundary lands inside an emoji or a combined character, you get half a character:
</p>

<pre><code>"Hello 👋".slice(0, 7);
// "Hello \ud83d"  (a lone surrogate that renders as �)

"Family: 👨👩👧 trip".slice(0, 10);
// "Family: 👨"  (the family emoji became a single man)

encodeURIComponent("Hi 👋".slice(0, 4));
// URIError: URI malformed</code></pre>

<p>
    That last one is a real production crash: truncate a string for a URL slug or analytics event and the encoder throws. Segment by graphemes instead:
</p>

<pre><code>export function truncate(text, max, locale = "en") {
  const segmenter = new Intl.Segmenter(locale, { granularity: "grapheme" });
  const graphemes = Array.from(segmenter.segment(text), (s) =&gt; s.segment);
  if (graphemes.length &lt;= max) return text;
  return graphemes.slice(0, max - 1).join("") + "...";
}

truncate("Family: 👨👩👧 trip", 10); // "Family: 👨👩👧..."

export const graphemeLength = (text, locale = "en") =&gt;
  [...new Intl.Segmenter(locale, { granularity: "grapheme" }).segment(text)].length;

graphemeLength("👨👩👧"); // 1
graphemeLength("नमस्ते");  // 3</code></pre>

<p>
    For visual truncation, prefer CSS (<code>text-overflow: ellipsis</code> or <code>line-clamp</code>). The browser truncates on rendered width, which handles every script correctly and adapts to the longer strings translations produce. Reserve JavaScript truncation for places CSS cannot reach, such as push notifications, SMS, meta descriptions, and database fields.
</p>

<h3>Character limits on inputs</h3>

<p>
    HTML <code>maxlength</code> counts UTF-16 code units, so a 280-character limit allows only 70 four-unit emoji. If your counter shows "characters remaining," compute it with <code>graphemeLength()</code> so the number matches what the user sees, and enforce the real storage limit separately on the server.
</p>

<h2>Bug 2: Comparing Strings That Look Identical</h2>

<p>
    "é" can be stored as one code point (U+00E9) or as "e" followed by a combining acute accent (U+0301). They render identically and compare as different:
</p>

<pre><code>const composed = "é";    // é
const decomposed = "é"; // é

composed === decomposed;                                    // false
composed.normalize("NFC") === decomposed.normalize("NFC"); // true</code></pre>

<p>
    macOS file names, copy-paste from PDFs, and some mobile keyboards produce decomposed text. If you deduplicate usernames, look up tags, or compare translation keys, two visually identical values will not match. <strong>Normalize to NFC on input</strong>, at the point where text enters your system, and store the normalized form:
</p>

<pre><code>const clean = (input) =&gt; input.trim().normalize("NFC");</code></pre>

<p>
    For security-sensitive identifiers such as usernames, look at NFKC normalization and confusable-character detection too. "аdmin" with a Cyrillic "а" is not "admin," and normalization alone will not catch it.
</p>

<h2>Bug 3: Sorting Alphabetically</h2>

<p>
    <code>Array.prototype.sort()</code> without a comparator orders by UTF-16 code unit, which puts every uppercase letter before every lowercase one and every accented letter after Z:
</p>

<pre><code>["Zebra", "apple", "banana", "Äpfel"].sort();
// ["Zebra", "apple", "banana", "Äpfel"]  (wrong in every language)

["Zebra", "apple", "banana", "Äpfel"].sort(new Intl.Collator("de").compare);
// ["Äpfel", "apple", "banana", "Zebra"]</code></pre>

<p>
    "Alphabetical" also differs between languages. Swedish treats "å", "ä", and "ö" as separate letters at the end of the alphabet, after Z:
</p>

<pre><code>["Zebra", "apple", "zoo", "Ångström", "Äpfel"].sort(new Intl.Collator("sv").compare);
// ["apple", "Zebra", "zoo", "Ångström", "Äpfel"]</code></pre>

<p>
    Collator options handle two other common needs:
</p>

<pre><code>// Natural sort: file2 before file10
["file10", "file2", "file1"].sort(new Intl.Collator("en", { numeric: true }).compare);
// ["file1", "file2", "file10"]

// Case- and accent-insensitive equality
const base = new Intl.Collator("en", { sensitivity: "base" });
base.compare("resume", "Résumé"); // 0 (equal)</code></pre>

<p>
    <code>localeCompare()</code> uses the same machinery, but it creates a collator on every call. For sorting a large list, construct one <code>Intl.Collator</code> and pass its <code>compare</code> method. And remember that your database sorts too: <code>ORDER BY name</code> uses the column's collation, which should match the user's language for sorted lists to be consistent between server-side pagination and client-side sorting.
</p>

<h2>Bug 4: Searching and Matching Without Accents</h2>

<p>
    Users type "creme brulee" and expect to find "Crème Brûlée." Decompose with NFD and strip the combining marks to build a search key:
</p>

<pre><code>const searchKey = (text) =&gt;
  text.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase();

searchKey("Crème Brûlée"); // "creme brulee"</code></pre>

<p>
    Use this for matching and indexing, never for display. And be aware it is a language-specific simplification: in Spanish, "ñ" is a separate letter from "n," and in German, users may type "ae" for "ä." For anything beyond simple filtering, a search engine with per-language analyzers is the right tool.
</p>

<h2>Bug 5: Changing Case</h2>

<pre><code>"I".toLowerCase();             // "i"
"I".toLocaleLowerCase("tr");   // "ı"  (dotless i in Turkish)
"i".toLocaleUpperCase("tr");   // "İ"  (dotted capital I)
"ß".toUpperCase();             // "SS" (one character becomes two)</code></pre>

<p>
    Two rules follow. For user-facing text, use <code>toLocaleUpperCase(locale)</code> and <code>toLocaleLowerCase(locale)</code> with the content's language. For machine identifiers such as enum values, HTTP headers, and keys, use plain <code>toLowerCase()</code>, so a Turkish user's environment never turns <code>"TITLE"</code> into a key that does not exist.
</p>

<p>
    And avoid uppercasing translated strings in code at all. Use CSS <code>text-transform: uppercase</code> with a correct <code>lang</code> attribute, which lets the browser apply the language's rules, or let translators supply the uppercase form when it matters.
</p>

<h2>Bug 6: Validating Names and Text With ASCII Regex</h2>

<p>
    <code>/^[A-Za-z]+$/</code> rejects "Zoë," "José," "Nguyễn," and "李." People should not have to misspell their own names to sign up. Use Unicode property escapes, which require the <code>u</code> flag:
</p>

<pre><code>/^[A-Za-z]+$/.test("Zoë");  // false
/^\p{L}+$/u.test("Zoë");     // true
/^\p{L}+$/u.test("李");      // true

// Without the u flag, "." matches half an emoji
/^.$/.test("😀");   // false
/^.$/u.test("😀");  // true</code></pre>

<p>
    For names, the safest validation is almost none: required, trimmed, normalized, and within a length limit. Names can contain spaces, hyphens, apostrophes, and characters from any script. Many people have one name only, and many have no concept of "first" and "last."
</p>

<h2>Bug 7: Word Boundaries in Languages Without Spaces</h2>

<p>
    Splitting on spaces to count words or highlight terms fails for Japanese, Chinese, and Thai, which do not separate words with spaces. <code>Intl.Segmenter</code> handles word segmentation too:
</p>

<pre><code>const words = (text, locale) =&gt;
  [...new Intl.Segmenter(locale, { granularity: "word" }).segment(text)]
    .filter((s) =&gt; s.isWordLike)
    .map((s) =&gt; s.segment);

words("Hello, world! It's 2026.", "en"); // ["Hello", "world", "It's", "2026"]
words("สวัสดีครับ", "th");                  // ["สวัสดี", "ครับ"]
words("東京都に住んでいます", "ja");          // ["東京", "都", "に", "住", "んで", "い", "ます"]</code></pre>

<p>
    Word counts for CJK text are usually measured in characters instead, which is worth knowing if you enforce length limits by word count in a review or content tool.
</p>

<h2>Beyond JavaScript: Bytes and Storage</h2>

<p>
    Storage limits are usually in bytes, not characters, and UTF-8 uses one to four bytes per code point:
</p>

<pre><code>new TextEncoder().encode("hello").length; // 5
new TextEncoder().encode("héllo").length; // 6
new TextEncoder().encode("日本").length;   // 6
new TextEncoder().encode("👋").length;     // 4</code></pre>

<ul>
    <li><strong>MySQL</strong>: the legacy <code>utf8</code> charset (an alias for <code>utf8mb3</code>) stores at most three bytes per character and cannot store emoji. Use <code>utf8mb4</code>.</li>
    <li><strong>Byte-limited fields</strong>, such as some push notification payloads, HTTP headers, and cookie values, need a byte-aware truncation that still respects grapheme boundaries.</li>
    <li><strong>Encoding</strong>: declare <code>&lt;meta charset="utf-8"&gt;</code>, send <code>Content-Type: ...; charset=utf-8</code>, and save <a href="/blogs/json-translation-files-explained/">translation files</a> as UTF-8 without a byte order mark. Text such as <code>Ã©</code> where <code>é</code> should be is UTF-8 that something decoded as Latin-1.</li>
</ul>

<h2>Checklist</h2>

<ul>
    <li>Count and truncate with <code>Intl.Segmenter</code> graphemes, or let CSS truncate.</li>
    <li>Normalize user input to NFC before storing or comparing it.</li>
    <li>Sort with a cached <code>Intl.Collator</code> for the user's locale.</li>
    <li>Build accent-insensitive search keys with NFD plus <code>\p{M}</code> removal.</li>
    <li>Use locale-aware case functions for text and plain ones for identifiers.</li>
    <li>Validate with <code>\p{L}</code> and the <code>u</code> flag, and validate names minimally.</li>
    <li>Use <code>utf8mb4</code> in MySQL and UTF-8 everywhere else.</li>
</ul>

<p>
    String handling and formatting go together. For numbers, dates, and currency, see our <a href="/blogs/javascript-intl-api-dates-numbers-currencies/">guide to the JavaScript Intl API</a>, and to catch these bugs before your users do, run your UI through a <a href="/blogs/pseudo-localization-testing-guide/">pseudo-locale</a>.
</p>

<h2>Frequently Asked Questions</h2>

<h3>Why does "👍🏽".length return 4 in JavaScript?</h3>
<p>
    JavaScript's length property counts UTF-16 code units. The thumbs-up emoji and the skin-tone modifier are each outside the Basic Multilingual Plane, so each needs two code units, for a total of four. Use <code>Intl.Segmenter</code> with grapheme granularity to count the single visible character.
</p>

<h3>How do I truncate a string without breaking emoji?</h3>
<p>
    Split the string into grapheme clusters with <code>new Intl.Segmenter(locale, { granularity: "grapheme" })</code>, take the first N segments, and join them. Slicing by index can cut a surrogate pair or an emoji sequence in half.
</p>

<h3>How do I sort strings alphabetically in JavaScript for any language?</h3>
<p>
    Create an <code>Intl.Collator</code> for the user's locale and pass its compare method to <code>sort()</code>. The default sort orders by code unit, which places uppercase before lowercase and accented letters after Z.
</p>

<h3>What is Unicode normalization and when should I use it?</h3>
<p>
    Normalization converts equivalent sequences of code points, such as a precomposed "é" and "e" plus a combining accent, into one consistent form. Normalize user input to NFC before storing or comparing it so that visually identical text compares as equal.
</p>

<h3>Does Intl.Segmenter work in all browsers?</h3>
<p>
    Yes. Intl.Segmenter is supported in current versions of Chrome, Edge, Safari, and Firefox, and in Node.js, so it is safe to use without a polyfill for modern browser targets.
</p>

<h2>The Short Version</h2>

<p>
    A character is not a code unit, equality needs normalization, alphabetical order depends on language, and ASCII regex rejects real people. Use <code>Intl.Segmenter</code>, <code>normalize("NFC")</code>, <code>Intl.Collator</code>, and <code>\p{L}</code>, and most of these bugs disappear before your first non-English user signs up.
</p>

<p>
    Robust string handling makes your code ready for every language. <a href="/">Babelize</a> takes care of the other half, translating and syncing your UI strings in every locale as part of your Git workflow.
</p>
---
- [All articles](https://www.babelize.co/blogs)