← All Tools

When It Looks Like a Palindrome to You But Not to the Algorithm — The Code Point vs. Grapheme Trap

Guide · Last verified Aug 27, 2026

For a string made only of letters, like "racecar," every tool agrees on whether it's a palindrome. The trouble starts when emoji get mixed in — especially flags or emoji composed from multiple parts, like skin-tone variants. A string that's clearly symmetric to your eye can come back "not a palindrome," or the reverse: something that looks asymmetric can come back "is a palindrome." The root cause is that there are two different definitions of "how many characters" a string has.

1. Three units for splitting a Unicode string

Splitting a Unicode string can be done at roughly three different levels. The shallowest is the UTF-16 code unit (what JavaScript's old .length and [i] indexing operate on). Above that is the code point (the unit that JavaScript's [...str] spread or a for...of loop iterates over). And the one closest to how a human actually perceives text is the grapheme cluster (the unit Intl.Segmenter produces — what shows up on screen as "one character"). For common characters like the Latin alphabet or precomposed Hangul, all three units line up exactly. But for flag emoji or emoji joined with a ZWJ (Zero Width Joiner), multiple code points combine into a single grapheme.

2. How a flag emoji is actually constructed

🇰🇷 (the South Korean flag) isn't really a single character — it's a combination of two Regional Indicator Symbol code points, U+1F1F0 (🇰) and U+1F1F7 (🇷). To a human eye it's one flag image, but counted by code point it's two. 👨‍👩‍👧 (the family emoji) is even more extreme: it's three separate emoji — man, woman, girl — joined by two ZWJ characters (U+200D), making it five or more code points. [...str] has no knowledge of these joining rules, so it treats each code point as an independent "character" and reverses their order individually.

3. How code-point-level reversal produces the wrong verdict

The core of a palindrome check is "reverse the normalized string and compare it to the original." The Palindrome Checker reverses with [...n].reverse().join(''), which is a code-point-level reversal. If a single flag is made of two code points (in order A, B), this reversal logic doesn't move the flag as a whole to the back — it reverses the order of A and B individually. Reverse a string that concatenates two different flags (say, 🇰🇷🇯🇵) and the four Regional Indicator code points get shuffled together, which can produce flag combinations that don't actually exist, or cause an array of flags that was visually symmetric to be judged asymmetric at the code-point level.

Example: Reverse "🇰🇷🇰🇷" (the same flag repeated twice — 2 graphemes, 4 code points) at the code-point level, and only the code-point order gets flipped, leaving the resulting string identical to the original (it's a symmetric arrangement, so the problem happens to stay hidden). But reverse the two different flags "🇰🇷🇯🇵," and the four code points get rearranged into the order [🇯, 🇵, 🇰, 🇷] — when the rendering engine re-pairs these into adjacent Regional Indicator pairs, it can display combinations that never existed in the original (an arbitrary pairing like "🇯🇰" + "🇵🇷"). If the reversal were done at the grapheme level (via Intl.Segmenter) instead, only [🇯🇵, 🇰🇷] would flip order, and this recombination problem wouldn't occur.

4. Which approach does this tool actually use?

Checking the actual palindrome-checker.html code confirms it operates at the code-point level ([...str]) and does not use Intl.Segmenter. The tool's own FAQ and SEO description already state that "compound emoji made of multiple code points may produce unexpected results," so this limitation was acknowledged from the design stage. For plain text — Hangul, Latin letters, digits, and punctuation — code-point-level and grapheme-level results are identical, so this is a non-issue for most real-world use. But if you want to check the exact composition of a string mixing flags or ZWJ emoji, the Unicode Inspector gives a more precise, code-point-by-code-point breakdown.

5. Where this shows up in practice

The same trap shows up when counting the character count of text that includes emoji. The Emoji Counter and the Text Statistics tool can likewise disagree — "this sentence has 3 emoji" vs. "5 emoji" — depending on whether they count by code point or by grapheme. For tasks where an accurate count actually matters, like a social media character limit, it's worth checking which unit a tool uses before relying on it.

Frequently Asked Questions

Q. Does this problem also show up when checking Korean- or English-only text?

A. No. For precomposed Hangul, Latin letters, digits, and ordinary punctuation, one code point maps exactly to one grapheme, so a code-point-level reversal and a grapheme-level reversal produce identical results. The problem only occurs when multiple code points render as a single character — flags, ZWJ-joined emoji, or emoji with skin-tone modifiers.

Q. Does using Intl.Segmenter get rid of this problem completely?

A. It resolves it in most cases. Intl.Segmenter's grapheme-level splitting follows the Unicode text segmentation standard (UAX #29), so it groups ZWJ combinations and Regional Indicator pairs into the same units a human perceives. However, it's a relatively recent browser API, so it may not be supported in older browsers.

Q. Is it okay to use this tool for real work, like automated text processing?

A. For most use cases involving plain text — Hangul, Latin letters, digits, symbols — it's accurate with no issues. But for an automation pipeline handling user-generated content that's heavy on emoji (social media posts, for example), it's worth adding your own verification logic to account for the code-point-level limitation described above.

Q. Are the case/whitespace/punctuation-ignoring options related to this problem?

A. No, they're a separate feature. Handling case, whitespace, and punctuation happens during the normalization step, via toLowerCase() and Unicode-property regexes. The code point vs. grapheme problem occurs in the following step — the reversal (reverse()) itself. The two steps operate independently of each other.