Why encodeURIComponent throws URIError on a cut emoji: lone surrogates

Cut an emoji in half: encodeURIComponent throws URIError, TextEncoder writes EF BF BD. Lone UTF-16 surrogates and isWellFormed()/toWellFormed(), per ECMA-262 and Unicode 17.0.

DevelopmentEncodingUnicode

A username cut short for a URL can end like this:

const cut = "ab😀".slice(0, 3); // "ab" + "\uD83D"
encodeURIComponent(cut);        // URIError
new TextEncoder().encode(cut);  // 61 62 EF BF BD

One call throws, the other quietly changes the bytes. The cause is a lone surrogate.

Half an emoji

In UTF-16, 😀 (U+1F600) is two code units, D83D DE00 (Unicode, Table 3-5). slice counts code units, so only D83D survives.

Unicode sets D800–DFFF aside for surrogates and excludes them from scalar values (D71–D76). A lone surrogate code unit is ill-formed UTF-16 (D91). A JavaScript string is a sequence of 16-bit values, so it can hold one anyway.

No place in UTF-8

RFC 3629 §3 prohibits encoding U+D800–U+DFFF in UTF-8, so an API producing UTF-8 must stop or substitute.

encodeURI and encodeURIComponent stop: ECMA-262’s Encode (19.2.6.5) throws URIError on an unpaired surrogate. Unlike the decoding error in the URL encoding post, this one happens when encoding.

TextEncoder substitutes. encode() takes a USVString, and the Encoding Standard notes that this conversion turns lone surrogates into U+FFFD (�), which is EF BF BD in UTF-8. The emoji is lost without an error.

Check, then choose

ECMA-262’s isWellFormed() (22.1.3.10) detects lone surrogates; toWellFormed() (22.1.3.31) returns a copy with each one replaced by U+FFFD.

cut.isWellFormed();                     // false
encodeURIComponent(cut.toWellFormed()); // "ab%EF%BF%BD"

� still loses data, so avoid cutting by code units; Why character counts disagree covers cutting by code points or visible characters. Check isWellFormed() where a string leaves as bytes, and pick the exception or the replacement on purpose.

Sources

Links and content were checked on 8 October 2026.

Back to the blog