<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mr.Rain</title>
    <description>The latest articles on DEV Community by Mr.Rain (@rain_880203).</description>
    <link>https://dev.to/rain_880203</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4124096%2F40d20760-9511-48f3-a5ae-78cd9e07b23c.png</url>
      <title>DEV Community: Mr.Rain</title>
      <link>https://dev.to/rain_880203</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rain_880203"/>
    <language>en</language>
    <item>
      <title>Your MDX Blog Will Throw EvalError on Cloudflare Workers. Here's the Fix.</title>
      <dc:creator>Mr.Rain</dc:creator>
      <pubDate>Sun, 01 Nov 2026 08:30:42 +0000</pubDate>
      <link>https://dev.to/rain_880203/your-mdx-blog-will-throw-evalerror-on-cloudflare-workers-heres-the-fix-3fkg</link>
      <guid>https://dev.to/rain_880203/your-mdx-blog-will-throw-evalerror-on-cloudflare-workers-heres-the-fix-3fkg</guid>
      <description>&lt;p&gt;Our bilingual product blog had a bug that never appeared locally, never appeared in&lt;br&gt;
&lt;code&gt;next build&lt;/code&gt;, and turned every blog post into an HTTP 500 in production — on exactly one&lt;br&gt;
runtime. The error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EvalError: Code generation from strings disallowed for this context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are shipping a Next.js app to Cloudflare Workers (we run&lt;br&gt;
&lt;a href="https://christmasphotoai.com" rel="noopener noreferrer"&gt;Christmas Photo AI&lt;/a&gt; on Workers via OpenNext) and your&lt;br&gt;
content pipeline uses &lt;code&gt;next-mdx-remote&lt;/code&gt;, this post is your future. Here is what happens,&lt;br&gt;
why, and the surprisingly small fix.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that only exists in production
&lt;/h2&gt;

&lt;p&gt;The stack: Next.js 16 App Router, React 19, MDX posts rendered through&lt;br&gt;
&lt;code&gt;next-mdx-remote/rsc&lt;/code&gt;. Everything was green locally with &lt;code&gt;next dev&lt;/code&gt;, green in CI, and the&lt;br&gt;
build itself succeeded. Deployed to Workers, the home page, style pages, and pricing all&lt;br&gt;
worked — but every single blog URL returned a 500.&lt;/p&gt;

&lt;p&gt;That gap is the first lesson: &lt;strong&gt;the compile step that hurts you doesn't run where you&lt;br&gt;
test.&lt;/strong&gt; &lt;code&gt;next build&lt;/code&gt; pre-renders what it can, but request-time rendering on a different&lt;br&gt;
runtime is a different execution environment with different rules.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Workers disallow it
&lt;/h2&gt;

&lt;p&gt;Cloudflare Workers run with V8 isolates configured to forbid dynamic code evaluation —&lt;br&gt;
&lt;code&gt;eval()&lt;/code&gt; and &lt;code&gt;new Function(...)&lt;/code&gt; both throw &lt;code&gt;EvalError&lt;/code&gt;. It is a security posture, not a&lt;br&gt;
bug, and it will not change.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next-mdx-remote&lt;/code&gt;'s whole job is turning MDX source into executable JavaScript at runtime.&lt;br&gt;
Under the hood it compiles the MDX and evaluates the result as a function. On Node.js&lt;br&gt;
that's fine. On Workers it is precisely the operation the platform forbids.&lt;/p&gt;

&lt;p&gt;So the 500 was correct behavior. Our dependency was doing the one thing this runtime&lt;br&gt;
refuses to do — at request time, in production only.&lt;/p&gt;
&lt;h2&gt;
  
  
  The audit that made the decision easy
&lt;/h2&gt;

&lt;p&gt;Before choosing a replacement, we asked a simpler question: &lt;strong&gt;do we actually render any&lt;br&gt;
MDX?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer was no. The posts were plain GFM — headings, paragraphs, lists, tables, links.&lt;br&gt;
No imported components, no custom JSX inside the content. We were paying for a runtime&lt;br&gt;
MDX compiler to render… markdown.&lt;/p&gt;

&lt;p&gt;That reframed the fix from "find an MDX compiler that works on Workers" to "use a&lt;br&gt;
markdown parser." Which is a much smaller problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: marked + sanitize + one CSS block
&lt;/h2&gt;

&lt;p&gt;We switched to &lt;a href="https://marked.js.org/" rel="noopener noreferrer"&gt;&lt;code&gt;marked&lt;/code&gt;&lt;/a&gt; — a pure parser, no code generation —&lt;br&gt;
and ran its HTML output through a sanitizer before injecting it. Rough shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sanitizeHtml&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/security/sanitize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderPostBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sanitizeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component layer renders that with &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;, and a &lt;code&gt;.blog-prose&lt;/code&gt;&lt;br&gt;
style block (semantic tokens, table overflow handling for narrow screens) keeps the&lt;br&gt;
typography looking like the rest of the site.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next-mdx-remote&lt;/code&gt; and &lt;code&gt;remark-gfm&lt;/code&gt; left &lt;code&gt;package.json&lt;/code&gt; the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three gotchas from the migration
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Sanitizer allowlists eat attributes silently.&lt;/strong&gt; Our sanitizer stripped unknown&lt;br&gt;
attributes, and &lt;code&gt;rel&lt;/code&gt; wasn't on the allowlist — so the transform adding&lt;br&gt;
&lt;code&gt;rel="noreferrer"&lt;/code&gt; to external links ran, and then the sanitizer stripped it right back&lt;br&gt;
off. If you sanitize, audit the allowlist against the transforms you actually apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Soft 404s hide behind optimistic UI.&lt;/strong&gt; While testing the migration we found retired&lt;br&gt;
blog URLs were rendering the not-found page with a &lt;strong&gt;200 status&lt;/strong&gt;. The culprit was a&lt;br&gt;
locale-level &lt;code&gt;loading.tsx&lt;/code&gt; boundary that swallowed &lt;code&gt;notFound()&lt;/code&gt; semantics. If a URL&lt;br&gt;
should be gone, verify it returns a real 404 — not a friendly page with a 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Test on the runtime you ship.&lt;/strong&gt; The entire class of bug disappears if your CI or a&lt;br&gt;
pre-deploy step runs a smoke test on the actual Workers runtime (or &lt;code&gt;wrangler dev&lt;/code&gt; with&lt;br&gt;
the Workers-compatible mode). &lt;code&gt;next dev&lt;/code&gt; on Node proves nothing about &lt;code&gt;EvalError&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should keep MDX
&lt;/h2&gt;

&lt;p&gt;To be clear: if your content genuinely embeds React components, MDX is the right tool,&lt;br&gt;
and there are compile-at-build-time approaches that work on Workers. The mistake wasn't&lt;br&gt;
using MDX — it was shipping a &lt;strong&gt;runtime compiler&lt;/strong&gt; for content that never used the&lt;br&gt;
compiler's features.&lt;/p&gt;

&lt;p&gt;The rule we took away:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Ship a parser for content, a compiler only for code — and know which one your&lt;br&gt;
dependency actually is.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;The blog this fix landed on belongs to &lt;a href="https://christmasphotoai.com" rel="noopener noreferrer"&gt;Christmas Photo AI&lt;/a&gt;&lt;br&gt;
— turn one photo into a private AI Christmas portrait in about two minutes. First&lt;br&gt;
portrait is free.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>mdx</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Your MDX Blog Will Throw EvalError on Cloudflare Workers. Here's the Fix.</title>
      <dc:creator>Mr.Rain</dc:creator>
      <pubDate>Sun, 01 Nov 2026 08:02:31 +0000</pubDate>
      <link>https://dev.to/rain_880203/your-mdx-blog-will-throw-evalerror-on-cloudflare-workers-heres-the-fix-4ibe</link>
      <guid>https://dev.to/rain_880203/your-mdx-blog-will-throw-evalerror-on-cloudflare-workers-heres-the-fix-4ibe</guid>
      <description>&lt;p&gt;Our bilingual product blog had a bug that never appeared locally, never appeared in&lt;br&gt;
&lt;code&gt;next build&lt;/code&gt;, and turned every blog post into an HTTP 500 in production — on exactly one&lt;br&gt;
runtime. The error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EvalError: Code generation from strings disallowed for this context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are shipping a Next.js app to Cloudflare Workers (we run&lt;br&gt;
&lt;a href="https://christmasphotoai.com" rel="noopener noreferrer"&gt;Christmas Photo AI&lt;/a&gt; on Workers via OpenNext) and your&lt;br&gt;
content pipeline uses &lt;code&gt;next-mdx-remote&lt;/code&gt;, this post is your future. Here is what happens,&lt;br&gt;
why, and the surprisingly small fix.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that only exists in production
&lt;/h2&gt;

&lt;p&gt;The stack: Next.js 16 App Router, React 19, MDX posts rendered through&lt;br&gt;
&lt;code&gt;next-mdx-remote/rsc&lt;/code&gt;. Everything was green locally with &lt;code&gt;next dev&lt;/code&gt;, green in CI, and the&lt;br&gt;
build itself succeeded. Deployed to Workers, the home page, style pages, and pricing all&lt;br&gt;
worked — but every single blog URL returned a 500.&lt;/p&gt;

&lt;p&gt;That gap is the first lesson: &lt;strong&gt;the compile step that hurts you doesn't run where you&lt;br&gt;
test.&lt;/strong&gt; &lt;code&gt;next build&lt;/code&gt; pre-renders what it can, but request-time rendering on a different&lt;br&gt;
runtime is a different execution environment with different rules.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Workers disallow it
&lt;/h2&gt;

&lt;p&gt;Cloudflare Workers run with V8 isolates configured to forbid dynamic code evaluation —&lt;br&gt;
&lt;code&gt;eval()&lt;/code&gt; and &lt;code&gt;new Function(...)&lt;/code&gt; both throw &lt;code&gt;EvalError&lt;/code&gt;. It is a security posture, not a&lt;br&gt;
bug, and it will not change.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next-mdx-remote&lt;/code&gt;'s whole job is turning MDX source into executable JavaScript at runtime.&lt;br&gt;
Under the hood it compiles the MDX and evaluates the result as a function. On Node.js&lt;br&gt;
that's fine. On Workers it is precisely the operation the platform forbids.&lt;/p&gt;

&lt;p&gt;So the 500 was correct behavior. Our dependency was doing the one thing this runtime&lt;br&gt;
refuses to do — at request time, in production only.&lt;/p&gt;
&lt;h2&gt;
  
  
  The audit that made the decision easy
&lt;/h2&gt;

&lt;p&gt;Before choosing a replacement, we asked a simpler question: &lt;strong&gt;do we actually render any&lt;br&gt;
MDX?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer was no. The posts were plain GFM — headings, paragraphs, lists, tables, links.&lt;br&gt;
No imported components, no custom JSX inside the content. We were paying for a runtime&lt;br&gt;
MDX compiler to render… markdown.&lt;/p&gt;

&lt;p&gt;That reframed the fix from "find an MDX compiler that works on Workers" to "use a&lt;br&gt;
markdown parser." Which is a much smaller problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: marked + sanitize + one CSS block
&lt;/h2&gt;

&lt;p&gt;We switched to &lt;a href="https://marked.js.org/" rel="noopener noreferrer"&gt;&lt;code&gt;marked&lt;/code&gt;&lt;/a&gt; — a pure parser, no code generation —&lt;br&gt;
and ran its HTML output through a sanitizer before injecting it. Rough shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sanitizeHtml&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/security/sanitize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderPostBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sanitizeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component layer renders that with &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;, and a &lt;code&gt;.blog-prose&lt;/code&gt;&lt;br&gt;
style block (semantic tokens, table overflow handling for narrow screens) keeps the&lt;br&gt;
typography looking like the rest of the site.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next-mdx-remote&lt;/code&gt; and &lt;code&gt;remark-gfm&lt;/code&gt; left &lt;code&gt;package.json&lt;/code&gt; the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three gotchas from the migration
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Sanitizer allowlists eat attributes silently.&lt;/strong&gt; Our sanitizer stripped unknown&lt;br&gt;
attributes, and &lt;code&gt;rel&lt;/code&gt; wasn't on the allowlist — so the transform adding&lt;br&gt;
&lt;code&gt;rel="noreferrer"&lt;/code&gt; to external links ran, and then the sanitizer stripped it right back&lt;br&gt;
off. If you sanitize, audit the allowlist against the transforms you actually apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Soft 404s hide behind optimistic UI.&lt;/strong&gt; While testing the migration we found retired&lt;br&gt;
blog URLs were rendering the not-found page with a &lt;strong&gt;200 status&lt;/strong&gt;. The culprit was a&lt;br&gt;
locale-level &lt;code&gt;loading.tsx&lt;/code&gt; boundary that swallowed &lt;code&gt;notFound()&lt;/code&gt; semantics. If a URL&lt;br&gt;
should be gone, verify it returns a real 404 — not a friendly page with a 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Test on the runtime you ship.&lt;/strong&gt; The entire class of bug disappears if your CI or a&lt;br&gt;
pre-deploy step runs a smoke test on the actual Workers runtime (or &lt;code&gt;wrangler dev&lt;/code&gt; with&lt;br&gt;
the Workers-compatible mode). &lt;code&gt;next dev&lt;/code&gt; on Node proves nothing about &lt;code&gt;EvalError&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should keep MDX
&lt;/h2&gt;

&lt;p&gt;To be clear: if your content genuinely embeds React components, MDX is the right tool,&lt;br&gt;
and there are compile-at-build-time approaches that work on Workers. The mistake wasn't&lt;br&gt;
using MDX — it was shipping a &lt;strong&gt;runtime compiler&lt;/strong&gt; for content that never used the&lt;br&gt;
compiler's features.&lt;/p&gt;

&lt;p&gt;The rule we took away:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Ship a parser for content, a compiler only for code — and know which one your&lt;br&gt;
dependency actually is.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;The blog this fix landed on belongs to &lt;a href="https://christmasphotoai.com" rel="noopener noreferrer"&gt;Christmas Photo AI&lt;/a&gt;&lt;br&gt;
— turn one photo into a private AI Christmas portrait in about two minutes. First&lt;br&gt;
portrait is free.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>mdx</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>What's the Average Reading Speed? 238 WPM, and How to Measure Your Own Honestly</title>
      <dc:creator>Mr.Rain</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:52:33 +0000</pubDate>
      <link>https://dev.to/rain_880203/whats-the-average-reading-speed-238-wpm-and-how-to-measure-your-own-honestly-4b5c</link>
      <guid>https://dev.to/rain_880203/whats-the-average-reading-speed-238-wpm-and-how-to-measure-your-own-honestly-4b5c</guid>
      <description>&lt;p&gt;Quick answer before any code: &lt;strong&gt;the average adult reading speed in English is about 238 WPM for nonfiction, 260 WPM for fiction, and 183 WPM reading aloud&lt;/strong&gt; — from a 2019 meta-analysis of 190 studies. If your homemade WPM meter says you read 600, the meter is more likely broken than you are exceptional. This post covers what the benchmark actually measures, the three ways reading-speed tools lie, and how to build an honest one in a few dozen lines.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zqtp6m3krbik1l56b0v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zqtp6m3krbik1l56b0v.png" alt="Terminal mockup: measuring reading pace — 1,240 words in 5 minutes 12 seconds of active time equals 238 wpm, right at the adult average" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 238 WPM number comes from
&lt;/h2&gt;

&lt;p&gt;In 2019, Marc Brysbaert published a meta-analysis of reading-rate research in the &lt;em&gt;Journal of Memory and Language&lt;/em&gt; — 190 studies pooled into reference numbers per reading mode:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reading mode&lt;/th&gt;
&lt;th&gt;Average pace&lt;/th&gt;
&lt;th&gt;Why it differs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Silent reading, nonfiction&lt;/td&gt;
&lt;td&gt;238 WPM&lt;/td&gt;
&lt;td&gt;Reading for understanding — the default "reading speed"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Silent reading, fiction&lt;/td&gt;
&lt;td&gt;260 WPM&lt;/td&gt;
&lt;td&gt;Predictable narrative carries the reader&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading aloud&lt;/td&gt;
&lt;td&gt;183 WPM&lt;/td&gt;
&lt;td&gt;Speech production caps the pace&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two properties of these numbers matter for how you use them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They are &lt;strong&gt;population medians with wide spread&lt;/strong&gt;, not thresholds. Plenty of healthy adults sit below or above with full comprehension.&lt;/li&gt;
&lt;li&gt;They are &lt;strong&gt;mode-dependent&lt;/strong&gt;. Quoting "the average reading speed" without the mode and material is like quoting "the average HTTP response time" without mentioning which endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the full breakdown — age curves, the fiction/nonfiction split, and why Chinese reading speed is a different metric entirely — the &lt;a href="https://lumenread.net/blog/average-reading-speed" rel="noopener noreferrer"&gt;full benchmarks article breaks it down&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways WPM meters lie
&lt;/h2&gt;

&lt;p&gt;I've benchmarked a few reading trackers, and nearly every wrong number traces back to one of three bugs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Wall-clock time.&lt;/strong&gt; The timer runs while you're answering Slack. Your "reading time" inflates, your WPM &lt;em&gt;deflates&lt;/em&gt;, and nobody notices. Fair measurement counts &lt;strong&gt;active visible reading time&lt;/strong&gt; — pause the clock when the tab loses focus or the user stops scrolling. This is the single biggest error, and it's pure code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The stopping-point assumption.&lt;/strong&gt; Dividing total word count by elapsed time assumes the reader finished. If they bailed at 40%, you're computing their speed over text they never read. Use the actual furthest position reached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Segmentation.&lt;/strong&gt; &lt;code&gt;text.split(" ")&lt;/code&gt; is not word counting. It mangles punctuation, em dashes, and — completely — any CJK text. The same article counted by a regex tool and by a proper segmenter can differ by several percent. That's before we even discuss languages without spaces (more below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting words the way browsers do
&lt;/h2&gt;

&lt;p&gt;The platform gives you a proper tokenizer for free: &lt;code&gt;Intl.Segmenter&lt;/code&gt;, which implements UAX #29 word boundaries — the same standard browsers use for double-click word selection. If it's good enough for text selection, it's good enough for a WPM meter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;word&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isWordLike&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;activeReadingSeconds&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wpm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9uceyuzo1843a5vx2g20.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9uceyuzo1843a5vx2g20.png" alt="Code card: counting words with Intl.Segmenter word-like segments and dividing by active reading minutes" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Node 16+, all modern browsers, and Bun support it. &lt;code&gt;isWordLike&lt;/code&gt; filters out whitespace and punctuation segments, so "state-of-the-art" and "don't" get counted the way a reader experiences them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting active time like a benchmark study
&lt;/h2&gt;

&lt;p&gt;The measurement-method section of &lt;a href="https://lumenread.net/blog/average-reading-speed" rel="noopener noreferrer"&gt;the average-reading-speed research summary&lt;/a&gt; makes the same point the meta-analysis data shows: studies that time &lt;em&gt;self-paced silent reading&lt;/em&gt; and count only real reading produce the trustworthy numbers. In the browser, that's a visibility listener plus an idle check, roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;activeMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastTick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visibilitychange&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// pause when the tab is hidden; reading stopped&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibilityState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;userIsReading&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;activeMs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastTick&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;lastTick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Divide segmenter-counted words by &lt;em&gt;that&lt;/em&gt; number, and your meter stops lying. One more honesty rule from the research playbook: treat results under ~60 seconds or under 80% completion as low-confidence samples, the same way you'd distrust a benchmark that exited before the warmup finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read Chinese or Japanese
&lt;/h2&gt;

&lt;p&gt;Then "words per minute" is the wrong unit, and no conversion factor fixes it. Chinese-primary text is measured in &lt;strong&gt;CPM — characters per minute&lt;/strong&gt;, counting Han characters and excluding punctuation and whitespace. Japanese similarly counts Han characters plus kana. A property test for your counter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;countHan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Script=Han}/gu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key discipline is separation: &lt;strong&gt;WPM for space-delimited languages, CPM for Han-script languages, and never a fixed ratio between them.&lt;/strong&gt; Comparing your English WPM to a Chinese CPM number is a category error, not a stat difference. The &lt;a href="https://lumenread.net/blog/average-reading-speed" rel="noopener noreferrer"&gt;benchmark guide covers the CPM side in detail&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0sa4ueoa283ok16v76g4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0sa4ueoa283ok16v76g4.png" alt="Bar chart of adult reading benchmarks in dark theme: 183 wpm aloud, 238 wpm nonfiction, 260 wpm fiction" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So what's a &lt;em&gt;good&lt;/em&gt; reading speed?
&lt;/h2&gt;

&lt;p&gt;The engineering answer: there is no SLA for reading. 238 WPM on typical nonfiction is the reference point, not the bar — a dense spec read at 180 with full comprehension beats a 400 WPM skim you can't recall. The useful "benchmark" is a personal baseline: same kind of text, same purpose, honest active-time measurement, tracked over weeks. If you want to see where you stand against the research numbers — one unpaced read of a reviewed article, no sign-up, quiz optional — &lt;a href="https://lumenread.net/blog/average-reading-speed" rel="noopener noreferrer"&gt;the full guide and test are here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is 200 WPM slow?&lt;/strong&gt;&lt;br&gt;
Slightly under the nonfiction average and, on its own, meaningless. On dense material with strong comprehension it's a healthy result. Compare sessions on similar material before worrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my tracker's number 40% lower than the average?&lt;/strong&gt;&lt;br&gt;
Almost always wall-clock time or pause handling. Fix the measurement before concluding anything about your reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I trust speed-reading tools claiming 1,000+ WPM?&lt;/strong&gt;&lt;br&gt;
The reading research reviewed in the meta-analysis doesn't support dramatic multipliers: past roughly twice the average pace, comprehension degrades faster than speed improves. Skimming is real; "full-comprehension 1,000 WPM" is not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Cross-post note: this summarizes research written up in full at &lt;a href="https://lumenread.net/blog/average-reading-speed" rel="noopener noreferrer"&gt;LumenRead's average reading speed guide&lt;/a&gt; — that's the canonical version if you want to cite one link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>learning</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Validate LLM Output: Catching AI-Invented Emoji</title>
      <dc:creator>Mr.Rain</dc:creator>
      <pubDate>Wed, 07 Oct 2026 13:30:20 +0000</pubDate>
      <link>https://dev.to/rain_880203/validate-llm-output-catching-ai-invented-emoji-4bf9</link>
      <guid>https://dev.to/rain_880203/validate-llm-output-catching-ai-invented-emoji-4bf9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhkfeti86z1dxu6agvx8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhkfeti86z1dxu6agvx8.jpg" alt="emoji tokens flowing left to right through a glowing validation gate, flat illustration" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If an LLM generates structured output for your product, any string it emits can be fiction, and "looks right" is not validation. On my site &lt;a href="https://emoji-combos.net" rel="noopener noreferrer"&gt;emoji-combos.net&lt;/a&gt;, a language model drafts decorative emoji combos for bio text, and it kept producing emoji that don't exist in the Unicode standard: valid-looking sequences that no platform actually supports. The fix is deterministic and cheap: a &lt;code&gt;Set&lt;/code&gt; of every real emoji sequence, &lt;code&gt;Intl.Segmenter&lt;/code&gt; to split strings into user-perceived characters, and a pipeline that fails closed when validation rejects too much. This post walks through all three, with the code.&lt;/p&gt;

&lt;p&gt;We'll cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why LLMs hallucinate emoji (and why a better model is not the answer)&lt;/li&gt;
&lt;li&gt;Why &lt;code&gt;[...str]&lt;/code&gt; and &lt;code&gt;str.split('')&lt;/code&gt; break emoji, and the correct segmentation&lt;/li&gt;
&lt;li&gt;Building the ground-truth dictionary from Unicode's official data&lt;/li&gt;
&lt;li&gt;The full validator: skin tones, ZWJ sequences, invisible characters&lt;/li&gt;
&lt;li&gt;Pipeline policy: batch minimums, dedupe, fail-closed behavior&lt;/li&gt;
&lt;li&gt;Two gotchas that cost me an evening each&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The stack is TypeScript, but nothing here is stack-specific. The concepts port to Python, Go, or wherever your AI feature lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature and the lie
&lt;/h2&gt;

&lt;p&gt;The pipeline behind this is deliberately boring. When a visitor searches a "vibe" the site hasn't curated, the server calls an OpenAI-compatible chat endpoint (DeepSeek in production, any &lt;code&gt;/chat/completions&lt;/code&gt; provider works) with JSON mode on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/chat/completions`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deepseek-chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_PROMPT&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Generate 10 emoji combos for the vibe &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;response_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json_object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system prompt asks for &lt;code&gt;{slug, name, description, combos[]}&lt;/code&gt;: ten short decorative strings for social bios. Temperature runs at 1.2 because variety is the product.&lt;/p&gt;

&lt;p&gt;And the model lies. Not always, and less with each model generation, but reliably enough that shipping on vibes alone is malpractice. The lies take a specific shape: &lt;strong&gt;emoji-shaped sequences that aren't in the Unicode standard&lt;/strong&gt;. A "family" with zero-width joiners in the wrong order. A skin-tone modifier glued to a base that doesn't accept one. Two emoji fused into something no consortium ever approved.&lt;/p&gt;

&lt;p&gt;This isn't a malfunction. An LLM predicts plausible token sequences; a plausible sequence that's fake is a successful prediction by its own lights. Emoji make the problem unusually crisp because validity has a public ground truth: Unicode's &lt;a href="https://www.unicode.org/Public/emoji/" rel="noopener noreferrer"&gt;emoji-test data&lt;/a&gt; defines exactly which sequences are real. That turns "is this AI output acceptable?" from a judgment call into a lookup.&lt;/p&gt;

&lt;p&gt;That's the position you want to be in with any AI feature. A registry exists for your domain (Unicode, IANA, ISO codes, your product database), so the model proposes and the registry disposes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q63dh6xgn4fpzecowxj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q63dh6xgn4fpzecowxj.jpg" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Split strings like a human sees them
&lt;/h2&gt;

&lt;p&gt;Before validating anything, you need the right unit. This is where nearly everyone starts wrong, including me.&lt;/p&gt;

&lt;p&gt;Emoji are multi-code-point. A family emoji like 👨‍👩‍👧‍👦 is &lt;code&gt;man + ZWJ + woman + ZWJ + girl + ZWJ + boy&lt;/code&gt;, where ZWJ (U+200D) is an invisible joiner. The two classic JavaScript string-splitting idioms both operate below that level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Splits by UTF-16 code units: destroys ANY emoji with a surrogate pair&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byCodeUnit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;🎀🫧✨&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Splits by code points: keeps solo emoji, SHREDS joined sequences&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byCodePoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;👨‍👩‍👧‍👦&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;// ['👨', '\u200D', '👩', '\u200D', '👧', '\u200D', '👦'] — 7 fragments&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code-point splitting &lt;em&gt;sounds&lt;/em&gt; like the fix and demos fine on &lt;code&gt;🎀&lt;/code&gt;. Then it quietly dismantles every ZWJ sequence into components that are individually valid emoji but collectively wrong, and your validator flags good output as garbage (or worse, passes fragments through as "known" and reassembles nonsense).&lt;/p&gt;

&lt;p&gt;The correct tool is &lt;code&gt;Intl.Segmenter&lt;/code&gt;, which has been baseline in Node and browsers for a while now and asks the platform's own ICU for &lt;em&gt;user-perceived characters&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segmenter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;grapheme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;splitGraphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;segmenter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;splitGraphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;🎀👨‍👩‍👧‍👦✨&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ['🎀', '👨‍👩‍👧‍👦', '✨'] — 3 units, family intact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One API call, and the unit of validation matches the unit of meaning. Everything downstream now operates on graphemes, never on spread results.&lt;/p&gt;

&lt;p&gt;If you're in Python, the equivalent is &lt;code&gt;grapheme&lt;/code&gt; or a regex with &lt;code&gt;\X&lt;/code&gt;. The language doesn't matter; the principle does: &lt;strong&gt;validate at the granularity your user perceives&lt;/strong&gt;, or your checks measure the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Build the dictionary from the source of truth
&lt;/h2&gt;

&lt;p&gt;Unicode publishes the complete list of valid emoji sequences in the &lt;a href="https://www.unicode.org/Public/emoji/latest/emoji-test.txt" rel="noopener noreferrer"&gt;emoji-test.txt&lt;/a&gt; file. Every line describes one sequence the standard considers fully-qualified emoji. That file is your registry.&lt;/p&gt;

&lt;p&gt;I wrote a small import script that parses it into a JSON array of sequence strings, checked into the repo as &lt;code&gt;src/data/emoji/sequences.json&lt;/code&gt;, 3,564 of them at the time of import. Regenerating is one command when Unicode ships a new revision.&lt;/p&gt;

&lt;p&gt;At runtime it becomes a &lt;code&gt;Set&lt;/code&gt;, because membership checks are O(1) and the whole thing fits in memory comfortably:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;sequences&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/data/emoji/sequences.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KNOWN_SEQUENCES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sequences&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties of this approach deserve emphasis:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's complete by construction.&lt;/strong&gt; Whatever the model invents, if it isn't in the file, it isn't an emoji. No fuzzy matching, no embedding similarity, no second model scoring the first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's auditable.&lt;/strong&gt; When validation rejects something, "why" has a one-line answer: not in Unicode's list. Try getting that clarity from a moderation model's score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's swappable.&lt;/strong&gt; New Unicode revision → re-run the import. The validation contract never changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note what we did &lt;em&gt;not&lt;/em&gt; do: ask the LLM to "only use valid emoji" harder. Prompt constraints are suggestions; a &lt;code&gt;Set.has()&lt;/code&gt; is a fact. Use prompts to steer quality (variety, style, tone) and code to enforce correctness. Conflating the two is how teams end up with prompt paragraphs doing a schema's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The validator
&lt;/h2&gt;

&lt;p&gt;Here's the whole thing, lightly trimmed from production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Skin tone modifiers are valid components, but only glued to a person emoji.&lt;/span&gt;
&lt;span class="c1"&gt;// Matching the tone-stripped form covers every mixed-tone combination&lt;/span&gt;
&lt;span class="c1"&gt;// (🧑🏻‍🤝‍🧑🏿) without enumerating hundreds of variants.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TONE_MODIFIERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;{1F3FB}-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;{1F3FF}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/gu&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EXTENDED_PICTOGRAPHIC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Extended_Pictographic}/u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Zero-width and bidi control characters are never legitimate in a combo:&lt;/span&gt;
&lt;span class="c1"&gt;// platforms use them for mention/tag injection tricks.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INVISIBLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;{200B}-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;{200F}&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;{202A}-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;{202E}&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;{2060}-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;{206F}&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;{FEFF}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segmenter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;grapheme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ComboValidationResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** Graphemes that are emoji-like but not in the official dictionary. */&lt;/span&gt;
  &lt;span class="nl"&gt;invalidEmoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="cm"&gt;/** Zero-width / bidi control characters are present. */&lt;/span&gt;
  &lt;span class="nl"&gt;hasInvisible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isKnownEmoji&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grapheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;KNOWN_SEQUENCES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grapheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;KNOWN_SEQUENCES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grapheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TONE_MODIFIERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;splitGraphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;segmenter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateComboContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ComboValidationResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invalidEmoji&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hasInvisible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grapheme&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;splitGraphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INVISIBLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grapheme&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;hasInvisible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// A cluster built only from tones is junk: presentation needs a base.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toneStripped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;grapheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TONE_MODIFIERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toneOnlyCluster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toneStripped&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;grapheme&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;toneStripped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;looksLikeEmoji&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EXTENDED_PICTOGRAPHIC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grapheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;toneOnlyCluster&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;looksLikeEmoji&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isKnownEmoji&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grapheme&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;invalidEmoji&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grapheme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;invalidEmoji&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;invalidEmoji&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;hasInvisible&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things are happening, and each one earns its keep:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skin-tone stripping.&lt;/strong&gt; A handshake between people with different skin tones is a single grapheme, but Unicode doesn't enumerate every tone mix in emoji-test. Stripping modifiers before the lookup collapses all variants onto a base sequence that &lt;em&gt;is&lt;/em&gt; in the dictionary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extended_Pictographic as the "looks like emoji" test.&lt;/strong&gt; The Unicode property &lt;code&gt;\p{Extended_Pictographic}&lt;/code&gt; matches emoji-ish characters broadly, broader than the official emoji set. That asymmetry is exactly the hallucination signature: &lt;em&gt;emoji-shaped, but not in the registry&lt;/em&gt;. Plain text (letters, digits, punctuation) fails the property and passes through for length/content rules elsewhere; fake emoji trip it and get checked against the &lt;code&gt;Set&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tone-only cluster guard.&lt;/strong&gt; After segmentation, a cluster of &lt;em&gt;only&lt;/em&gt; tone modifiers (sometimes glued to a stray space) is garbage with no base character. It doesn't match Extended_Pictographic on its own, so the explicit check catches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invisible character rejection.&lt;/strong&gt; Zero-width joiners, word joiners, and bidi controls are legitimate in some writing systems, which is precisely why they're a documented trick for splitting @mentions in pasted bios. For a site whose output gets pasted into profile fields on other platforms, this is a real attack surface, not paranoia. The regex rejects the lot.&lt;/p&gt;

&lt;p&gt;Per combo, all of this runs in microseconds. The LLM call it guards costs five orders of magnitude more. Guard rails are cheap; use them everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Pipeline policy, the part that makes it a system
&lt;/h2&gt;

&lt;p&gt;A validator is a function. A pipeline is a policy about what happens to the function's answers. Ours, in production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request 10, require 4.&lt;/strong&gt; Each generation asks for ten combos. If fewer than four survive validation, the entire batch fails. No "serve whatever made it." A 60%+ rejection rate means something upstream is wrong (bad prompt drift, a model regression), and shipping the survivors means shipping a biased sample of a broken process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;filterValidCombos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;generated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;combos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_VALID_COMBOS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AiGenerationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;generation produced too few valid combos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dedupe at the database, not just in memory.&lt;/strong&gt; The in-memory filter drops exact duplicates within a batch, but the model also regurgitates combos that already exist on a vibe. A unique constraint at the persistence layer is the last line of defense, and unique-violation errors during insert are tolerated as "already have it," not propagated as failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWithTags&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;combo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;tagIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A combo that already exists must not sink the batch.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isUniqueViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fail closed on the provider.&lt;/strong&gt; No API key configured? The route returns 503 and the frontend shows its normal empty state. Provider down or timing out? Same. The failure mode of an AI feature should be "honestly unavailable," never "confidently degraded."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache past the model.&lt;/strong&gt; Once a vibe has six approved combos stored, generation is skipped entirely for that query. The model is the cold-start path, not the hot path. (This also means validation made the feature &lt;em&gt;cheaper&lt;/em&gt;: trustworthy stored output is worth serving, and serving it skips the expensive, hallucination-prone step.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human gate on permanence.&lt;/strong&gt; Generated vibes are created as drafts, excluded from public listings and the sitemap until an admin promotes them from a review queue. The code validates correctness; the human validates taste (variety, on-theme-ness, non-cringe descriptions). Visitors who triggered a generation see their results immediately; only permanence is gated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn226k422x004x1hmlaxt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn226k422x004x1hmlaxt.jpg" alt="Abstract pipeline: three connected stations with emoji stickers traveling along a line, one station carrying a shield" width="799" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the gate
&lt;/h2&gt;

&lt;p&gt;The repo uses &lt;a href="https://fast-check.dev/" rel="noopener noreferrer"&gt;fast-check&lt;/a&gt; for property tests, and the validator is a property-test target from heaven: for any string, validation should never &lt;em&gt;accept&lt;/em&gt; an emoji-ish grapheme absent from the dictionary. Sketch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fast-check&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejects every non-dictionary emoji-shaped grapheme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constantFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;🎀&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;✨&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;🎃&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;u200D&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;u{1F3FF}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;minLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateComboContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Any emoji-looking grapheme in the result must be a known sequence.&lt;/span&gt;
        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;splitGraphemes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Extended_Pictographic}/u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;KNOWN_SEQUENCES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invalidEmoji&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generative testing shines here because the input space (all strings) is infinite and the property is crisp. It caught the tone-only cluster case during development, which I had not thought of manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas worth your evening
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;JSON mode is not a fidelity guarantee.&lt;/strong&gt; Even with &lt;code&gt;response_format: { type: 'json_object' }&lt;/code&gt;, models occasionally wrap the payload in markdown fences or lead with a chatty sentence. Parse tolerantly: try &lt;code&gt;JSON.parse&lt;/code&gt; on the trimmed body; on failure, slice from the first &lt;code&gt;{&lt;/code&gt; to the last &lt;code&gt;}&lt;/code&gt; and parse that. And validate the &lt;em&gt;shape&lt;/em&gt; (string slug, string array of combos) before touching values, because &lt;code&gt;payload.combos&lt;/code&gt; being &lt;code&gt;null&lt;/code&gt; is not an exception until you make it one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reasoning tokens eat your completion budget.&lt;/strong&gt; On some providers, hidden reasoning happens inside the same &lt;code&gt;max_tokens&lt;/code&gt; budget as the visible JSON. A ceiling that's generous for ten emoji strings can be too small for ten emoji strings &lt;em&gt;plus the model's private scratchpad&lt;/em&gt;, and the symptom is not an error but an empty completion, intermittently, which looks exactly like a network flake. If your structured calls come back empty on a reasoning-flavored model, raise the ceiling before you blame the network. (The system prompt in my pipeline also asks the model to reuse an existing vibe slug when the query is a synonym, "cute" vs "可爱", which cuts duplicate entities; canonicalization logic and JSON tolerance together mean the pipeline assumes the model is &lt;em&gt;helpfully sloppy&lt;/em&gt;, never precise.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An LLM's job is plausible output; your job is a truth source. Where a registry exists (and it exists more often than you'd think), validation is a lookup, not a model.&lt;/li&gt;
&lt;li&gt;Segment with &lt;code&gt;Intl.Segmenter&lt;/code&gt; before validating anything emoji-adjacent. &lt;code&gt;[...str]&lt;/code&gt; silently destroys exactly the sequences most likely to be hallucinated.&lt;/li&gt;
&lt;li&gt;Reject the &lt;em&gt;shape&lt;/em&gt; mismatch: &lt;code&gt;\p{Extended_Pictographic}&lt;/code&gt; matches but the dictionary misses. That's your hallucination signature.&lt;/li&gt;
&lt;li&gt;Policy beats function: batch minimums, DB-level dedupe, fail-closed provider handling, cache past the model, human gate on permanence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site is &lt;a href="https://emoji-combos.net" rel="noopener noreferrer"&gt;emoji-combos.net&lt;/a&gt;; the &lt;a href="https://emoji-combos.net/ai-emoji-combos" rel="noopener noreferrer"&gt;AI-assisted search&lt;/a&gt; is worth a try, and the &lt;a href="https://emoji-combos.net/blog/emoji-combos-guide" rel="noopener noreferrer"&gt;curated field guide&lt;/a&gt; is the human-picked counterpart. Questions, corrections, and stories of your own model's inventions: comments are open.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How much latency does validation add?&lt;/strong&gt;&lt;br&gt;
Microseconds per combo. The expensive step remains the LLM call itself; the dictionary is a static &lt;code&gt;Set&lt;/code&gt;, the segmenter is a platform API, and the regexes are linear scans over a few dozen graphemes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if Unicode publishes new emoji?&lt;/strong&gt;&lt;br&gt;
Re-run the import against the new emoji-test file and redeploy. The validation contract is unchanged; the dictionary is data, not code. Ship the data with the app (or load it at boot) so the runtime never depends on fetching it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work for non-emoji LLM output?&lt;/strong&gt;&lt;br&gt;
The mechanism generalizes to any domain with a canonical registry: check output against the registry at the unit users perceive, and reject on mismatch. The emoji-specific parts are the segmenter settings and the tone-stripping rule; everything else is domain-neutral policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why reject the whole batch below four valid combos?&lt;/strong&gt;&lt;br&gt;
Because survivors of a bad batch are a biased sample, and "ship whatever passed" hides upstream regressions. A hard floor turns the rejection rate into an observable signal instead of silent quality decay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not use a second model to check the first?&lt;/strong&gt;&lt;br&gt;
A second model has the same failure mode as the first: it predicts plausibility, it doesn't consult truth. Chains of models checking each other add latency, cost, and new hallucination surface, while a &lt;code&gt;Set.has()&lt;/code&gt; is exact. Use models for proposal; use registries for verdicts.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
