"πŸ™‚".length is 2 because a JavaScript string is a sequence of 16-bit UTF-16 code units, and length counts those units, not characters. Any character above U+FFFF, which includes most emoji, is stored as two units. To count what a reader sees, use Intl.Segmenter with grapheme granularity. To count code points, iterate the string. To count bytes, use TextEncoder.

What length counts

MDN puts it in one sentence: the length property "contains the length of the string in UTF-16 code units".

For English text, one letter is one unit and the answer looks right. It stops looking right as soon as the text contains emoji:

"hello".length;   // 5
"πŸ™‚".length;      // 2
"πŸ‘πŸ½".length;     // 4
"πŸ‡΅πŸ‡°".length;      // 4
"πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦".length;     // 11

Each of the last four lines is one visible character. The guide to emoji character counts shows what each one is made of.

Surrogate pairs

A 16-bit unit can hold 65,536 different values, and Unicode has far more code points than that. UTF-16 stores a code point above U+FFFF as two units: a high surrogate in the range D800 to DBFF followed by a low surrogate in the range DC00 to DFFF. Together they are a surrogate pair.

const smile = "πŸ™‚";                   // one code point: U+1F642

smile.length;                         // 2
smile.charCodeAt(0).toString(16);     // "d83d" (the high surrogate)
smile.charCodeAt(1).toString(16);     // "de42" (the low surrogate)
smile.codePointAt(0).toString(16);    // "1f642"
smile === "\u{1F642}";                // true

Most string methods work on these units. Indexing with text[i], charAt, charCodeAt, slice, substring and split("") all see the two halves of πŸ™‚ as separate items.

Four ways to measure a string

Code units: length

Use length when the thing you are comparing against also counts UTF-16 code units. That includes positions inside a JavaScript string and the HTML maxlength attribute.

Code points: the string iterator

The string iterator walks a string by code point. MDN's String reference says it "iterates by Unicode code points", so a surrogate pair comes out as one item. Spread syntax, Array.from and for...of all use it.

[..."πŸ™‚"].length;             // 1
Array.from("πŸ‘πŸ½").length;     // 2 (thumb + skin tone modifier)
[..."πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"].length;           // 7 (four people + three joiners)

let codePoints = 0;
for (const _ of "πŸ‡΅πŸ‡°") codePoints += 1;
codePoints;                   // 2

Code points are the right unit when a specification or a database counts them. They are still not what a reader sees: the family emoji is 7. The terms are explained in code points and grapheme clusters.

Visible characters: Intl.Segmenter

Intl.Segmenter with grapheme granularity splits text into grapheme clusters, the units a reader perceives as single characters. An emoji sequence, a flag, or a letter followed by a combining accent each come out as one segment.

const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });

[...segmenter.segment("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦")].length;          // 1
[...segmenter.segment("πŸ‘πŸ½πŸ‡΅πŸ‡°")].length;         // 2
[...segmenter.segment("cafe\u{301}")].length;  // 4 (the accent joins the e)

for (const { segment, index } of segmenter.segment("aπŸ™‚b")) {
  console.log(`${index}: ${segment}`);
}
// 0: a
// 1: πŸ™‚
// 3: b

Each segment carries its index in UTF-16 code units, which is why the last line says 3, not 2.

A reusable counter should create the segmenter once and should not throw in an engine that lacks it:

const graphemeSegmenter =
  typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
    ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
    : null;

function countCharacters(text) {
  if (!graphemeSegmenter) {
    // Fallback: count code points. This never splits a surrogate pair,
    // but it over-counts emoji sequences and decomposed accents.
    return Array.from(text).length;
  }
  let count = 0;
  for (const _ of graphemeSegmenter.segment(text)) count += 1;
  return count;
}

countCharacters("Hello, world!");   // 13
countCharacters("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦");            // 1 (7 with the fallback)
countCharacters("1οΈβƒ£πŸ‡΅πŸ‡°");           // 2 (5 with the fallback)

If you show the fallback's number to users, label it as approximate.

MDN's compatibility data lists the first versions with Intl.Segmenter as follows:

Browser or runtime First version
Chrome and Edge 87
Safari on macOS 14.1
Safari on iOS 14.5
Firefox 125
Node.js 16.0.0
Deno 1.8

Firefox was the last of the major browsers to add it, in version 125, released in April 2024. MDN describes the feature as working across the latest browsers since then, so the fallback mainly matters for older Firefox releases.

Bytes: TextEncoder

TextEncoder encodes a string as UTF-8 and returns a Uint8Array. Its length is the byte count. UTF-8 bytes vs characters explains why that differs from every other number here.

const encoder = new TextEncoder();

encoder.encode("hello").length;   // 5
encoder.encode("cafΓ©").length;    // 5 (Γ© is 2 bytes)
encoder.encode("πŸ™‚").length;      // 4
encoder.encode("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦").length;     // 25

Putting the four together, using countCharacters from above:

function measure(text) {
  return {
    utf16Units: text.length,
    codePoints: Array.from(text).length,
    characters: countCharacters(text),
    utf8Bytes: new TextEncoder().encode(text).length,
  };
}

measure("πŸ‘πŸ½");
// { utf16Units: 4, codePoints: 2, characters: 1, utf8Bytes: 8 }

Pitfalls

slice and substring can cut a character in half

Both methods take positions in code units, so a cut can fall between the two halves of a surrogate pair.

const text = "Hi πŸ™‚";

text.length;                        // 5
const cut = text.slice(0, 4);       // "Hi " plus half of the emoji
cut.length;                         // 4
cut.charCodeAt(3).toString(16);     // "d83d" (a high surrogate with no partner)
cut.isWellFormed();                 // false

The leftover half is called a lone surrogate. It does not represent any character, and TextEncoder writes it out as the replacement character U+FFFD. isWellFormed() detects the problem; MDN lists it as available across browsers since October 2023.

Cutting by code point avoids lone surrogates and can still break an emoji:

const family = "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦";

[...family].slice(0, 1).join("");   // "πŸ‘¨" (valid text, but no longer a family)

To shorten text for display, cut between visible characters:

function truncate(text, maxCharacters) {
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
  let result = "";
  let count = 0;
  for (const { segment } of segmenter.segment(text)) {
    if (count === maxCharacters) break;
    result += segment;
    count += 1;
  }
  return result;
}

truncate("Hi πŸ™‚!", 4);          // "Hi πŸ™‚"
truncate("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ and πŸ‡΅πŸ‡°", 1);     // "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"

maxlength counts UTF-16 code units

The HTML Standard measures maxlength with the same length as JavaScript.

<input type="text" maxlength="10">

This field accepts ten English letters or five πŸ™‚. It cannot hold a single πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦, which is 11 units.

Two consequences follow. A counter beside the field that shows visible characters will disagree with the browser, so the field can refuse input while the counter still shows room. Either count with value.length to match, or drop maxlength and validate in your own unit. Also, the Standard applies maxlength to edits made by the user: a value assigned from a script can be longer. The guide to character limits in online forms covers the server side.

The same text can have two lengths

An accented letter can be stored as one code point or as a base letter plus a combining mark. The two look identical and compare as different strings.

const composed = "caf\u{E9}";        // Γ© as one code point
const decomposed = "cafe\u{301}";    // e followed by a combining accent

composed === decomposed;                     // false
composed.length;                             // 4
decomposed.length;                           // 5
decomposed.normalize("NFC") === composed;    // true
decomposed.normalize("NFC").length;          // 4
composed.normalize("NFD").length;            // 5
countCharacters(composed);                   // 4
countCharacters(decomposed);                 // 4

normalize() with no argument uses NFC. Normalize both sides before comparing strings or enforcing a limit in code units or code points. Counting visible characters gives 4 either way.

Reversing a string

split("").reverse().join("") reverses code units, which turns every surrogate pair back to front. Reversing by code point fixes that and still breaks sequences:

const word = "cafe\u{301}!";         // "cafΓ©!" with a decomposed Γ©

[...word].reverse().join("") === "!\u{301}efac";
// true: the accent now sits on the exclamation mark

const reversedFlag = [..."πŸ‡΅πŸ‡°"].reverse().join("");
reversedFlag === "πŸ‡΅πŸ‡°";
// false: the two letter symbols are now K then P, which is a different flag

function reverseText(text) {
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
  const parts = Array.from(segmenter.segment(text), (part) => part.segment);
  return parts.reverse().join("");
}

reverseText(word) === "!e\u{301}fac";   // true: the accent stays on the e
reverseText("πŸ‡΅πŸ‡°!");                    // "!πŸ‡΅πŸ‡°"

Which one to use

You need to Use
Match maxlength, or index into a string text.length
Match a system that counts code points Array.from(text).length
Show a count to a person, or check a limit written for human readers Intl.Segmenter with granularity: "grapheme"
Check a byte limit for storage or an API new TextEncoder().encode(text).length
Truncate or reverse text for display Segment first, then operate on whole segments

To check your own results, paste the same text into the Character Counter. Its main figure is the visible character count from Intl.Segmenter, and Advanced statistics lists code points, UTF-16 code units and UTF-8 bytes, so all four numbers from this guide are there to compare. The methodology page describes each one.