Now try the same pattern on Chinese, Japanese, Russian, Greek, Hebrew, Thai and Hindi. Same answer every time:
| input | /\b\S+\b/g | | --- | --- | | the quick brown fox | 4 | | مرحبا بالعالم | 0 | | 你好世界 | 0 | | こんにちは世界 | 0 | | привет мир | 0 | | γεια σου κόσμε | 0 | | שלום עולם | 0 | | สวัสดีชาวโลก | 0 | | नमस्ते दुनिया | 0 |
I found this in my own code, in a diff tool that had been reporting "Removed: 0, Added: 0" over two visibly different panes. The panes were highlighted. The counter said nothing had changed. Both were right, according to the regex.
\b is a word boundary, meaning the position between a \w and a non-\w. And \w in JavaScript is exactly this:
That's it. ASCII letters, ASCII digits, underscore. Not "letters". Not "word characters" in any linguistic sense. Sixty-three specific code points.
So in مرحبا, there is no \w anywhere, which means there is no boundary anywhere, which means \b\S+\b never matches. The regex isn't failing. It is correctly reporting that a string of Arabic contains no ASCII word boundaries.
This is not a JavaScript quirk you can flag your way out of. The u flag doesn't change \w. Neither does v. \w is ASCII by specification and will stay that way.
The leading class is deliberate: a word has to start with a letter or digit, so a bare - or ... is not a word. The continuation class allows internal apostrophes and hyphens, so don't, it’s and well-known each count once. It agrees with \b\S+\b on every ASCII case I threw at it, and counts the rest of the world too.
This is the fix I shipped. It is also not the whole truth, and the next section is the part most posts leave out.
Devanagari over-splits. नमस्ते is written with combining marks. The virama ् and the vowel sign े are Unicode category Mn (nonspacing mark), which is neither \p{L} nor \p{N}. So the character class treats them as separators and shatters one word into pieces. The same applies across Indic scripts.
CJK under-splits. 你好世界 is two words, 你好 (hello) and 世界 (world), and the continuation class swallows the entire run as one match. There is no space to split on, and no amount of character-class tuning finds the boundary, because the boundary is a fact about Chinese, not about characters.
If you only serve Latin, Cyrillic, Greek, Arabic and Hebrew, the regex is fine and it's what I'd use. If Indic or CJK matters to you, it isn't.
Intl.Segmenter does real locale-aware segmentation, and it's been in every major browser since 2022 (Firefox was last, in 125):
isWordLike is what filters out the whitespace and punctuation segments. Without it you're counting separators too.
| input | \b\S+\b | \p{L} regex | Intl.Segmenter | | --- | --- | --- | --- | | the quick brown fox | 4 | 4 | 4 | | مرحبا بالعالم | 0 | 2 | 2 | | привет мир | 0 | 2 | 2 | | สวัสดีชาวโลก | 0 | 3 | 3 | | 你好世界 | 0 | 1 | 2 | | नमस्ते दुनिया | 0 | 5 | 2 | | ... --- !!! | 0 | 0 | 0 |
The cost is that it allocates a segment object per token, so it is meaningfully slower than a regex scan on large inputs. Worth measuring if you're counting on every keystroke. And it takes a locale, which you may not know.
String.prototype.length counts UTF-16 code units, not characters. Anything outside the Basic Multilingual Plane (emoji, most CJK extensions, historic scripts) is a surrogate pair and counts twice. Type three emoji into a counter built on .length and it tells you six.
That's the cheap fix and it's usually enough. But code points aren't characters either:
