What a surrogate pair is, and what a lone surrogate is
A JavaScript string is a sequence of 16-bit code units. Characters in the Basic Multilingual Plane fit in one unit. Everything above U+FFFF, including most emoji, needs two: a high surrogate (0xD800 to 0xDBFF) followed by a low surrogate (0xDC00 to 0xDFFF). Together they form a surrogate pair.
A lone surrogate is one half of that pair with no partner. It is not a valid character. It renders as a replacement box, encodeURIComponent throws a URIError on it, TextEncoder silently replaces it with U+FFFD, and many JSON serializers and databases reject or corrupt it. The bug is hard to notice because the broken string only shows up when real text contains an emoji at exactly the wrong position.
How slice, substr and charAt split emoji
length, slice, substring, substr and charAt all count code units, not characters. One emoji has a length of 2:
const s = 'hi \u{1F44B}';
s.length; // 5, not 4
s.slice(0, 4); // 'hi \uD83D', a lone high surrogate
[...s].slice(0, 4).join(''); // 'hi \u{1F44B}', code points are kept whole
Here are the usual suspects and what each one counts:
| Method | Counts | Can leave a lone surrogate |
|---|---|---|
slice(a, b), substring(a, b) | code units | yes |
substr(a, n) | code units | yes |
charAt(i), str[i] | code units | yes |
split('') | code units | yes |
[...str], for...of, Array.from | code points | no |
The classic victims are helper functions that every project has: truncate with an ellipsis, take the first letter for an avatar, pad or wrap to a column width.
export function truncate(text: string, max: number): string {
// bug: slice counts UTF-16 code units, so it can cut an emoji in half
return text.length > max ? text.slice(0, max) : text;
}
export function initials(name: string): string {
return name.charAt(0).toUpperCase();
}
With input '\u{1F600}' and a limit of 1, truncate returns '\uD83D'. initials does the same for a name that starts with an emoji.
Real bugs fixed in the wild
This is not a beginner mistake. It was fixed in google/budoux#1411 and microsoft/vscode-js-debug#2423, both merged. Those projects are maintained by people who know Unicode well, and the bug still got in, because unit tests rarely put an emoji at the cut point. The lesson is that you cannot rely on remembering; you need inputs that deliberately land on the boundary.
How to fix it
The smallest fix is to work in code points instead of code units:
export function truncate(text: string, max: number): string {
const chars = Array.from(text);
return chars.length > max ? chars.slice(0, max).join('') : text;
}
export function initials(name: string): string {
const [first = ''] = Array.from(name);
return first.toUpperCase();
}
That removes lone surrogates, but a code point is still not what a user sees as one character. Flags, skin tone sequences and family emoji are several code points joined together, and combining accents are separate code points as well. When you need user-perceived characters, use Intl.Segmenter with granularity: 'grapheme':
export function truncateGraphemes(text: string, max: number): string {
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
const parts = Array.from(seg.segment(text), (s) => s.segment);
return parts.length > max ? parts.slice(0, max).join('') : text;
}
As a last line of defense at an output boundary, String.prototype.toWellFormed() (Node 20+) replaces any lone surrogate with U+FFFD, and isWellFormed() lets you check first. Use them to contain damage, not to hide the cause.
How breakhound catches lone surrogates
For every string parameter, breakhound generates edge cases from the edgegoat package: emoji, flags, lone surrogates and other awkward Unicode, plus numeric arguments that move the cut position. Each call runs in an isolated child process with a timeout. The lone-surrogate-output rule then checks every string in the result and reports the shortest input that produces a broken one.
npx breakhound ./my-lib --rules lone-surrogate-output
breakhound v0.1.0 buggy-lib@1.0.0 (local)
23 functions analyzed, 1 skipped | 4444 calls | 6 time zones | seed 42 | 6.8s
HIGH (2)
[lone-surrogate-output] truncate returns a string with a lone surrogate (confidence high)
truncate('\u{1F600}', 1) -> '\ud83d'
[lone-surrogate-output] initials returns a string with a lone surrogate (confidence high)
initials('\u{1F600}') -> '\ud83d'
Each finding includes a reproduction script and a ready Vitest test you can keep as a regression test:
import { expect, test } from 'vitest';
import { truncate } from 'my-lib';
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
test("truncate returns well formed UTF-16", () => {
const result = truncate('\u{1F600}', 1);
expect(result).not.toMatch(LONE_SURROGATE);
});
The same run also reports crashes, NaN leaks and zone-dependent output, so one command covers more than strings. See finding bugs automatically in JavaScript for the full list of rules, timezone bugs for the date side, and fuzz testing TypeScript for how this compares with writing your own fuzzers.
breakhound executes the package code it analyzes. Run it on code you trust, or inside a container.
FAQ
What is a lone surrogate?
Half of a UTF-16 surrogate pair without its partner. It is not a valid character, and it breaks encodeURIComponent, TextEncoder round trips and many serializers.
Is code point slicing enough?
It avoids lone surrogates, but grapheme clusters such as flags, skin tone sequences and family emoji can still be split into odd pieces. Use Intl.Segmenter when the user sees the result.
How do I check a string for lone surrogates?
On Node 20 and newer, call str.isWellFormed(). On older runtimes, use a regular expression that matches an unpaired high or low surrogate, as in the generated test above.
Does substr have the same problem as slice?
Yes. substr is deprecated and counts code units like slice and substring, so it can cut a pair in half too.
Do I need to write emoji test cases myself?
No. breakhound reads your TypeScript types and feeds emoji, flags and lone surrogates to every string parameter automatically. You only run npx breakhound ./my-lib.