When a developer pastes a unicode Kaomoji into a chat box and the receiver's font renders it as tofu boxes, the root cause is almost always a misunderstanding of how those symbols are encoded, segmented, and counted by the surrounding system. This article walks through the underlying mechanics — code points, surrogate pairs, grapheme clusters, and platform limitations — so engineers can make informed decisions about copy-and-paste workflows, validation rules, and lint policies around face-symbol strings in code reviews and support tooling.
Kaomoji — face emoticons built from the CJK punctuation block — travel as plain text, not images. That sounds obvious until you start measuring them with .length in a console, scanning them with \\p{...} regexes, or letting users submit them into a search field that has a hard character cap. Each of those operations treats the input differently, and the gap between what the user sees and what the code counts is where most bugs hide.
What a Kaomoji Actually Is, Character-Wise
The classic Japanese-style emoticon (╯°□°)╯︵ ┻━┻ is not one character. It is a sequence of glyphs sourced mostly from the CJK Symbols and Punctuation block (U+3000 to U+303F) plus a handful of box-drawing characters and fullwidth punctuation. From JavaScript's perspective, the string "(╯°□°)╯︵ ┻━┻" has a .length of around 15 — far more than the six "characters" a human reader counts. That's because each ideograph or symbol occupies a single BMP code point, but the array of code points is longer than the array of visible glyphs.
A face emoticon with skin-tone modifiers, ZWJ sequences (for example the woman-and-man holding hands glyph that some power users insert at the end of an expression), or regional indicator pairs becomes even more misleading. The unicode consortium documents this explicitly: a single "character" can be a multi-code-point grapheme cluster, defined in Annex 29 of the Unicode Standard. If you want to count what the user perceives, you have to iterate graphemes, not surfaces.
This is also why some platforms display a (ㅅ´ ˘ ) perfectly fine while a ┬─┬ノ( º _ º ) — which contains the same box-drawing characters — survives transit on the same device. The encoding isn't the variable; the rendering pipeline, font coverage, and shaping engine are.
Tracking Each Number the Underlying Layer Sees
Developers who handle free-form input tend to bound it with limits that look reasonable and then discover, in production, that those limits are nonsense. A "max 140 characters" field capped at code units lets a single kaomoji eat a third of the budget. A "max 140 bytes" field on a UTF-8 channel lets an eight-symbol face eat 24 bytes and break assumptions in downstream log scrapers.
To illustrate, take ¯\_(ツ)_/¯. In UTF-8 this sequence is 12 bytes. In UTF-16 (the internal representation of strings in JavaScript, Java, C#, and Windows APIs), it is 10 code units, all in the BMP. Now insert a skin-tone variant like 👍🏽 — that's a base glyph (U+1F44D, 4 bytes) followed by a Fitzpatrick modifier (U+1F3FD, 4 bytes), totaling 8 bytes in UTF-8 and 4 code units in UTF-16. Add the ZWJ joiner (U+200D) and a second person glyph, and you've got a 17-byte cluster that looks like one icon but consumes six 16-bit code units.
The decision tree for an engineer ends up looking roughly like this:
- For length caps in user input, count grapheme clusters, not code points or code units. Browser-native
Intl.Segmenterdoes this in modern environments; the MDN Intl.Segmenter reference describes the constructor and itssegmentmethod. - For byte-budget transmission (logs, network packets, database column widths), count UTF-8 bytes, ideally via
TextEncoder. - For regex filtering that wants to admit or reject faces specifically, anchor on the CJK punctuation range, not on the surface glyph.
Treating the Bounding Box as a Counting Problem
Suppose your team runs an internal comment system with a 280-character display limit, modeled after a popular short-message platform. A user pastes (づ ̄ ³ ̄)づ. Most engineers, when first asked how many characters that consumes, answer "around nine" — which is roughly the grapheme count. But the systems that enforce the limit are usually counting differently:
-
Postgres
LENGTH()counts code points in the chosen encoding. For UTF-8 columns it gives 9. Forchar(20)declared columns, it gives 20 regardless. -
Python 3
len()on astrcounts code points. Same answer: 9. -
JavaScript
.lengthon astringcounts UTF-16 code units.9here, because every face-symbol in this particular emoticon sits inside the BMP. -
C#
string.Lengthcounts UTF-16 code units, identical to JavaScript. -
Array.from(str).lengthin JS counts code points. Identical result.
Where things diverge is with characters outside the BMP. Take a slightly fancier emoticon that includes a tone modifier or a regional flag (some users append 🏳️🌈 for emphasis). Now:
| Construct | Code points | UTF-16 code units | UTF-8 bytes |
|---|---|---|---|
(づ ̄ ³ ̄)づ |
9 | 9 | 15 |
👍🏽 |
2 | 4 | 8 |
🏳️🌈 |
4 | 7 | 14 |
The Python and Postgres counts go up by 2 for the thumbs-up; the JavaScript count goes up by 4. If you wrote a regression test that asserted "this string is 2 characters long" in JavaScript, the test would fail, and the failure would not be a bug — it would be the correct behavior of the encoding layer.
Picking a Linter that Excludes Circle-Glyph Submissions
Some compliance regimes require that user-generated free-text fields in moderation flows and ticket forms reject, allow, or replace certain sequences. The cleanest approach I've seen teams adopt:
- Define an allowlist of unicode blocks: Hiragana, Katakana, CJK Symbols and Punctuation, Box Drawing, and so on, depending on what your product actually supports.
- Compile the allowlist to a
RegExpusing\\p{Script=...}and\\p{Block=...}property escapes — these match on code points, not surfaces. They ship in the ECMA-262 regex spec and are available in V8, SpiderMonkey, and modern WebKit. The MDN reference at the unicode property escapes page is the stable documentation for this feature. - Test the compiled regex against a hand-curated set of face emoticons, including the edge ones —
¯\_(ツ)_/¯,ʕ•ᴥ•ʔ,(╯°□°)╯︵ ┻━┻, and at least one sequence containing a non-BMP code point.
If you need end-user helpers — say, a chat widget that lets people insert a face-symbol with a single tap instead of memorizing them — you can host a small picker. The in-depth walkthrough on setting up Kaomoji keyboard layouts on your device is a good reference for the underlying input methods, and you can reuse the same unicode-block allowlist on the receiving side.
Avoiding the Footguns That Show Up in Production
Three classes of bug recur in my incident reviews when teams ship face-symbol handling:
- Cluster truncation mid-grapheme. Splitting a string by code units at position N can sever a surrogate pair, leaving an unpaired high or low surrogate that renders as a replacement character. Always split by graphemes when the boundary might fall inside a multi-code-point sequence.
- Log shipping limits. Log aggregators with per-line byte caps will silently truncate or drop lines containing long sequences of face symbols. A single 280-grapheme comment containing 30 multi-byte face expressions can balloon past the cap.
- Search-token inflation. Treating each face expression as a single search token underestimates the index size. Inverted indexes built per code point handle this; index entries built per byte handle it less gracefully.
The pragmatic fix is to measure twice in the test suite: once with the encoding your runtime uses for length, and once with the encoding your storage uses for width. When they agree, your input is well-behaved. When they disagree by more than a factor of two, you have a cluster in the input and need a grapheme-aware pipeline.
A Short Checklist for Reviewing Face-Symbol Pipelines
Before shipping a feature that accepts face-symbol input, walk through this list:
- Decide which measurement unit governs the cap: name, count, byte, or code unit.
- Write a regression test that asserts the chosen measurement for at least one BMP-only and one non-BMP sequence.
- Validate regex property usage against the Unicode 15.1 standard via a known test file.
- Confirm the storage column width and the network framing both tolerate the longest expected face cluster.
- Confirm the rendering font on the smallest supported platform covers the box-drawing range.
- Document the choice in the team's style guide so future contributors don't re-litigate it.
Frequently asked questions
Why does emoji.face.length in JavaScript not match what I see?
The property returns the count of UTF-16 code units, not the count of visible glyphs. A face constructed entirely from BMP punctuation matches by coincidence; one that contains tone modifiers, ZWJ sequences, or regional indicators will exceed your visual count by a factor of two to four.
Should I store face symbols as varchar or text in Postgres?
Use text unless you have a hard, code-point-based reason to cap the width. The varchar(n) declaration interprets n as a code-point count in UTF-8 columns, which is friendlier than byte caps but still misaligns with the grapheme view you actually want to enforce.
Can a single Kaomoji break my byte-budget log shipping pipeline?
Yes. A short sequence of multi-byte glyphs can comfortably exceed 100 bytes, and modern face constructions that chain several box-drawing characters with fullwidth punctuation can land in the same range. Measure the longest expected input against your lowest per-line byte cap.
What's the simplest way to count what the user actually sees?
Use the Intl.Segmenter API with granularity: 'grapheme', or a polyfill that walks the cluster boundaries. Iterate the resulting segments to get a count that matches the human reader.
This article was drafted with AI assistance and reviewed for technical accuracy before publishing.
Top comments (0)