Skip to main content
Fundamentals · 7 min read

JSON, Unicode and Why Your Emoji Has Length 2

When to use \uXXXX, why an emoji counts as two characters, what a lone surrogate does, and the one escape JSON allows that nothing requires.

JSON strings are sequences of Unicode characters, and the format gives you two ways to write any character above ASCII: literally, or as a \uXXXX escape. Both produce exactly the same value.

JSON.parse('"\u00e9"')   // "é"
JSON.parse('"é"')        // "é"
// identical — same string, same length, same bytes

Which means the escape is never *required* for correctness. It is a transport convenience, for when you cannot trust everything between you and the reader to handle UTF-8.

The emoji

Here is the thing that catches everyone:

const s = JSON.parse('"😀"');
s.length            // 2
Buffer.byteLength(s) // 4
[...s].length       // 1

Three different answers for one emoji, and all three are correct — they measure different things.

MeasureValueWhat it counts
.length2UTF-16 code units. JavaScript strings are UTF-16, and anything above U+FFFF needs two of them.
byte length4Bytes once encoded as UTF-8 — what a network or a disk sees.
[...s].length1Code points. What a person would call "one character".

Characters above U+FFFF — emoji, many CJK extensions, historic scripts, mathematical symbols — are stored as a surrogate pair: two code units that mean one character only when read together. JSON can write them either way:

JSON.parse('"\ud83d\ude00"')  // "😀"  — the surrogate pair, escaped
JSON.parse('"😀"')             // "😀"  — the same thing, literally

Lone surrogates

Half a surrogate pair is not a valid Unicode character, but JSON will carry it and JavaScript will hold it:

const half = JSON.parse('"\ud83d"');
half.length           // 1
JSON.stringify(half)  // '"\ud83d"'  — escaped on the way out

Modern JSON.stringify emits lone surrogates as escapes rather than raw bytes, which is what makes that round trip survive. It is deliberate: a raw lone surrogate cannot be encoded as valid UTF-8, so a literal would produce a byte sequence no conforming decoder accepts.

Where they come from, almost always: a string truncated by code unit rather than by character. Cut "😀" at one unit and you have a lone high surrogate. If you truncate user text anywhere, truncate by code point.

The escapes JSON defines

EscapeMeansRequired?
\"Double quoteYes — it would end the string.
\\BackslashYes — it would start another escape.
\n \r \tNewline, carriage return, tabYes — raw control characters are not allowed in a JSON string.
\b \fBackspace, form feedYes, same reason.
\uXXXXAny code unitNo — optional for anything printable.
\/Forward slashNo, and it never was.

Every character below U+0020 must be escaped. A literal newline inside a string is a parse error, which is why a multi-line value arrives as "line one\nline two".

The slash escape nobody needs

\/ is legal and means exactly /:

JSON.parse('"slash\/escaped"')   // "slash/escaped"

It exists for one reason: so JSON can be embedded in an HTML <script> block without the sequence </script> inside a string ending the block early. Writing <\/script> avoids it.

Outside that specific case it is noise. Some older serialisers escape every slash, which is why URLs in JSON from certain PHP backends arrive as http:\/\/example.com — valid, equal to the unescaped form, and merely ugly. Re-serialising with the formatter normalises it away, because a parser produces the same string either way and JSON.stringify does not add the escape back.

Practical rules

  • Send UTF-8 and say so. Content-Type: application/json; charset=utf-8. JSON is Unicode by definition and UTF-8 is the default encoding.
  • Escape only when the path is untrustworthy. If something between you and the reader mangles non-ASCII, \uXXXX is pure ASCII and survives anything.
  • Measure in code points, not code units, whenever a human-facing limit is involved — character counters, truncation, column widths.
  • Check your database column type. MySQL utf8 is not UTF-8; it is three bytes per character and cannot store an emoji at all. utf8mb4 is the real one.
  • Never hand-escape. Use your language's serialiser; it knows the rules in the table above and you will not.

If a document is failing to parse and non-ASCII is involved, the validator gives the line, the column and the specific rule broken — usually a raw control character or a backslash that is not a valid escape.