# Pseudo-Localization: Catch i18n Bugs Before Translation

> Hardcoded strings, truncated buttons, and concatenated sentences are cheapest to fix before translation starts. Learn how pseudo-localization exposes them, build an ICU-safe pseudo-locale in 80 lines of JavaScript, and turn it into a CI check.
- **Author**: Mohit Sapat
- **Published**: 2026-10-05
- **URL**: https://www.babelize.co/blogs/pseudo-localization-testing-guide

---

<p class="lead">
    Most localization bugs are found by translators or, worse, by customers: a button label cut off in German, a sentence assembled in English word order, a hardcoded string nobody extracted. Pseudo-localization finds all of them on day one, before you pay for a single translation. This guide shows how it works, how to build a pseudo-locale for an ICU-based project in about eighty lines of JavaScript, and how to turn it into an automated check in CI.
</p>

<h2>What Pseudo-Localization Is</h2>

<p>
    Pseudo-localization generates a fake locale from your source strings. Each string stays readable to you, but it is transformed to stress your UI the way real translations will:
</p>

<pre><code>"Save changes"        ->  "[Šáṽé çĥáñĝéš~~~~~]"
"Hello, {name}!"      ->  "[Ĥéļļö, {name}!~~]"
"{count, plural, one {# file} other {# files}}"
                      ->  "[{count, plural, one {# ƒîļé} other {# ƒîļéš}}~~~~~~~]"</code></pre>

<p>
    Three transformations do the work:
</p>

<ul>
    <li><strong>Accented characters</strong> keep the text legible while proving it went through your translation pipeline. Plain English on a pseudo-localized screen is a hardcoded string.</li>
    <li><strong>Length padding</strong> of around 40% simulates longer languages like German and Finnish, exposing truncation and overflow.</li>
    <li><strong>Brackets</strong> mark where each string starts and ends. A missing closing bracket means truncation. Brackets in the middle of a sentence mean concatenation.</li>
</ul>

<p>
    It is not a new idea. Microsoft has shipped pseudo-locales (<code>qps-ploc</code>, <code>qps-ploca</code>, and the mirrored <code>qps-plocm</code>) in Windows for years, Android has built-in <code>en-XA</code> and <code>ar-XB</code> pseudo-locales, and Xcode offers accented, double-length, right-to-left, and bounded-string pseudolanguages. On the web, most teams simply never set it up.
</p>

<h2>The Bugs It Catches</h2>

<table>
    <thead>
        <tr>
            <th>What you see</th>
            <th>The bug</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>Settings</code> in plain English</td>
            <td>Hardcoded string, never extracted to a translation file</td>
        </tr>
        <tr>
            <td><code>[Šáṽé çĥá</code> with no closing bracket</td>
            <td>Fixed-width container truncating text</td>
        </tr>
        <tr>
            <td><code>[Ŷöû ĥáṽé] 3 [ñéŵ ɱéššáĝéš]</code></td>
            <td>Concatenation: the sentence is built from fragments, so translators cannot reorder it</td>
        </tr>
        <tr>
            <td>Text overlapping icons or wrapping badly</td>
            <td>Layout assumes English string lengths</td>
        </tr>
        <tr>
            <td>Boxes or <code>Ã©</code> instead of <code>é</code></td>
            <td>Font missing glyphs, or an encoding bug somewhere in the pipeline</td>
        </tr>
        <tr>
            <td><code>{name}</code> visible on screen</td>
            <td>Placeholder not interpolated, or broken during transformation</td>
        </tr>
    </tbody>
</table>

<p>
    Concatenation is the most valuable catch. It is invisible in English, impossible for a translator to fix, and expensive to refactor once many locales depend on the fragments. The fix is to move the whole sentence into one message with placeholders, which our <a href="/blogs/icu-messageformat-plurals-guide/">ICU MessageFormat guide</a> covers in detail.
</p>

<h2>How Much Longer Do Translations Get?</h2>

<p>
    Short strings expand the most, which is exactly where UI breaks: buttons, tabs, and badges. The commonly cited IBM globalization guideline for English source text, as reproduced by the <a href="https://www.w3.org/International/articles/article-text-size" target="_blank" rel="noopener noreferrer">W3C</a>:
</p>

<table>
    <thead>
        <tr>
            <th>English length</th>
            <th>Typical expansion</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Up to 10 characters</td>
            <td>100% to 200%</td>
        </tr>
        <tr>
            <td>11 to 20 characters</td>
            <td>80% to 100%</td>
        </tr>
        <tr>
            <td>21 to 30 characters</td>
            <td>60% to 80%</td>
        </tr>
        <tr>
            <td>31 to 50 characters</td>
            <td>40% to 60%</td>
        </tr>
        <tr>
            <td>51 to 70 characters</td>
            <td>31% to 40%</td>
        </tr>
        <tr>
            <td>Over 70 characters</td>
            <td>About 30%</td>
        </tr>
    </tbody>
</table>

<p>
    "Save" becomes "Speichern" in German. "Share" becomes "Compartilhar" in Portuguese. A flat 40% padding is a reasonable everyday default; switch to a double-length mode before a release to stress your shortest labels.
</p>

<p>
    Expansion is not only horizontal. Thai, Hindi, and Arabic scripts need more vertical space for stacked marks, so a fixed <code>height</code> with <code>overflow: hidden</code> clips them even when the width is fine. CJK languages are usually shorter in characters but wider per character.
</p>

<h2>Building a Pseudo-Locale for ICU Messages</h2>

<p>
    The tricky part is not accenting letters. It is <em>not</em> accenting the things that must survive untouched: placeholder names, plural and select keywords, and rich-text tags. Transform <code>{count, plural, one {# file}}</code> naively and you get <code>{çöûñţ, þļûŕáļ, ...}</code>, which breaks every message at runtime.
</p>

<p>
    This script walks each ICU message, transforms only literal text (including the text inside plural and select branches), and writes a new locale file:
</p>

<pre><code>// scripts/pseudo-localize.mjs
import { readFileSync, writeFileSync } from "node:fs";

const ACCENTS = {
  a: "á", b: "ƀ", c: "ç", d: "ð", e: "é", f: "ƒ", g: "ĝ", h: "ĥ", i: "î",
  j: "ĵ", k: "ķ", l: "ļ", m: "ɱ", n: "ñ", o: "ö", p: "þ", q: "ǫ", r: "ŕ",
  s: "š", t: "ţ", u: "û", v: "ṽ", w: "ŵ", x: "ẋ", y: "ý", z: "ž",
  A: "Å", B: "Ɓ", C: "Ç", D: "Ð", E: "É", F: "Ƒ", G: "Ĝ", H: "Ĥ", I: "Î",
  J: "Ĵ", K: "Ķ", L: "Ļ", M: "Ṁ", N: "Ñ", O: "Ö", P: "Þ", Q: "Ǫ", R: "Ŕ",
  S: "Š", T: "Ţ", U: "Û", V: "Ṽ", W: "Ŵ", X: "Ẋ", Y: "Ý", Z: "Ž",
};
const BRANCHING = new Set(["plural", "select", "selectordinal"]);

const transformText = (text) =>
  Array.from(text, (ch) => ACCENTS[ch] ?? ch).join("");

// Index of the "}" that closes the "{" at `start`.
function matchingBrace(str, start) {
  let depth = 0;
  for (let i = start; i &lt; str.length; i++) {
    if (str[i] === "{") depth++;
    else if (str[i] === "}" &amp;&amp; --depth === 0) return i;
  }
  return str.length;
}

// Transforms literal text but never {placeholders}, keywords, or &lt;tags&gt;.
function transformMessage(msg) {
  let out = "";
  let i = 0;
  while (i &lt; msg.length) {
    if (msg[i] === "&lt;" &amp;&amp; msg.indexOf("&gt;", i) !== -1) {
      const end = msg.indexOf("&gt;", i);
      out += msg.slice(i, end + 1);
      i = end + 1;
    } else if (msg[i] === "{") {
      const end = matchingBrace(msg, i);
      out += transformArgument(msg.slice(i + 1, end));
      i = end + 1;
    } else {
      out += transformText(msg[i]);
      i++;
    }
  }
  return out;
}

// {name} stays as-is; {count, plural, one {...}} recurses into each branch.
function transformArgument(body) {
  const [, type] = body.split(",").map((p) =&gt; p.trim());
  if (!BRANCHING.has(type)) return "{" + body + "}";

  const headerEnd = body.indexOf(",", body.indexOf(",") + 1) + 1;
  let result = "{" + body.slice(0, headerEnd);
  let rest = body.slice(headerEnd);
  while (rest.includes("{")) {
    const open = rest.indexOf("{");
    const close = matchingBrace(rest, open);
    result += rest.slice(0, open) + "{" + transformMessage(rest.slice(open + 1, close)) + "}";
    rest = rest.slice(close + 1);
  }
  return result + rest + "}";
}

// Brackets expose truncation and concatenation; ~ padding adds about 40%.
export function pseudoLocalize(message) {
  const body = transformMessage(message);
  const accented = body.match(/[^\x00-\x7F]/g)?.length ?? 0;
  return "[" + body + "~".repeat(Math.ceil(accented * 0.4)) + "]";
}

const walk = (obj) =&gt;
  Object.fromEntries(
    Object.entries(obj).map(([k, v]) =&gt; [k, typeof v === "string" ? pseudoLocalize(v) : walk(v)])
  );

const [input = "messages/en.json", output = "messages/en-XA.json"] = process.argv.slice(2);
writeFileSync(output, JSON.stringify(walk(JSON.parse(readFileSync(input, "utf8"))), null, 2) + "\n");</code></pre>

<p>
    Run it against your source file:
</p>

<pre><code>node scripts/pseudo-localize.mjs messages/en.json messages/en-XA.json</code></pre>

<p>
    Given this input:
</p>

<pre><code>{
  "nav": { "settings": "Settings" },
  "greeting": "Hello, {name}!",
  "files": "{count, plural, =0 {No files} one {# file} other {# files}}",
  "invite": "{inviter} invited you to &lt;b&gt;{team}&lt;/b&gt;."
}</code></pre>

<p>
    You get a file every ICU library will still parse:
</p>

<pre><code>{
  "nav": { "settings": "[Šéţţîñĝš~~~~]" },
  "greeting": "[Ĥéļļö, {name}!~~]",
  "files": "[{count, plural, =0 {Ñö ƒîļéš} one {# ƒîļé} other {# ƒîļéš}}~~~~~~~]",
  "invite": "[{inviter} îñṽîţéð ýöû ţö &lt;b&gt;{team}&lt;/b&gt;.~~~~~]"
}</code></pre>

<p>
    The script deliberately ignores ICU apostrophe escaping (<code>'{'</code>) to stay short. If your messages use it, or you want more modes, the <a href="https://formatjs.github.io/docs/tooling/cli" target="_blank" rel="noopener noreferrer">FormatJS CLI</a> can generate pseudo-locales from its full ICU parser with <code>formatjs compile --ast --pseudo-locale en-XA</code>.
</p>

<h3>Why en-XA?</h3>

<p>
    <code>XA</code> through <code>XZ</code> are private-use region codes in ISO 3166, so <code>en-XA</code> is a valid BCP 47 tag that will never collide with a real market. <code>Intl</code> formatters fall back to <code>en</code> for it, so dates and numbers still render. It also matches Android's convention. Our <a href="/blogs/locale-codes-bcp-47-explained/">locale codes guide</a> explains how tags and fallback work.
</p>

<h2>Wiring It Into Your App</h2>

<p>
    Register the pseudo-locale only outside production. With next-intl, for example:
</p>

<pre><code>// i18n/routing.ts
import { defineRouting } from "next-intl/routing";

const locales = ["en", "de", "ja"];
if (process.env.NODE_ENV !== "production") locales.push("en-XA");

export const routing = defineRouting({ locales, defaultLocale: "en" });</code></pre>

<p>
    Then visit <code>/en-XA/</code> and click through your main flows. Ten minutes on the pricing page, onboarding, settings, and transactional emails usually surfaces more localization bugs than a full translation round. If you are setting up next-intl from scratch, our <a href="/blogs/implement-i18n-nextjs-next-intl/">step-by-step Next.js i18n guide</a> covers the routing setup.
</p>

<p>
    Add a script so nobody has to remember the command:
</p>

<pre><code>{
  "scripts": {
    "i18n:pseudo": "node scripts/pseudo-localize.mjs messages/en.json messages/en-XA.json"
  }
}</code></pre>

<h2>Turning It Into a CI Check</h2>

<p>
    Visual review finds layout problems. Hardcoded strings can be caught automatically: in the pseudo-locale, any visible text containing plain ASCII words outside a bracketed string was not translated. A Playwright test can scan the DOM for them:
</p>

<pre><code>// e2e/pseudo-locale.spec.ts
import { test, expect } from "@playwright/test";

const PAGES = ["/en-XA/", "/en-XA/pricing", "/en-XA/settings"];

for (const path of PAGES) {
  test(`no untranslated text on ${path}`, async ({ page }) =&gt; {
    await page.goto(path);
    const leaks = await page.evaluate(() =&gt; {
      const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
      const found: string[] = [];
      while (walker.nextNode()) {
        const node = walker.currentNode;
        if (node.parentElement?.closest("script, style, code, [data-i18n-ignore]")) continue;
        // Remove bracketed strings, including ones split across elements.
        const outside = (node.textContent ?? "").replace(/\[[^\]]*(\]|$)|^[^\[]*\]/g, "");
        if (/[A-Za-z]{3,}/.test(outside)) found.push(outside.trim());
      }
      return found;
    });
    expect(leaks).toEqual([]);
  });
}</code></pre>

<p>
    Two tips for keeping this check reliable:
</p>

<ul>
    <li><strong>Seed test data with non-ASCII values</strong>, such as a user named "Zoë Ångström" and a team called "Ŧéäm", so interpolated user content does not trigger false positives.</li>
    <li><strong>Mark intentional English</strong>, such as brand names and code samples, with <code>data-i18n-ignore</code>, which also documents the decision for the next developer.</li>
</ul>

<p>
    Add a screenshot test on the same pages with your existing visual regression tooling and you will see truncation and overflow in pull requests instead of in bug reports from Germany.
</p>

<h2>Do Not Forget Right-to-Left</h2>

<p>
    An accented pseudo-locale tests length and extraction. It does not test direction. For that, render a second pseudo-locale with <code>dir="rtl"</code> on the <code>&lt;html&gt;</code> element, or just run <code>document.documentElement.dir = "rtl"</code> in your browser console. Icons that point the wrong way and margins on the wrong side show up immediately. Our <a href="/blogs/rtl-support-css-logical-properties/">RTL and CSS logical properties guide</a> covers the fixes.
</p>

<h2>Frequently Asked Questions</h2>

<h3>What is pseudo-localization?</h3>
<p>
    Pseudo-localization is a testing technique that generates a fake locale from your source strings by adding accents, padding, and brackets. The text stays readable, but it exposes hardcoded strings, truncation, concatenation, and encoding problems before real translation begins.
</p>

<h3>When should I run pseudo-localization?</h3>
<p>
    As early as possible and continuously after that. Running it before your first translation round is the cheapest point to fix layout and extraction bugs, and running it in CI prevents new hardcoded strings from being merged.
</p>

<h3>How much longer are translations than English?</h3>
<p>
    It depends on string length. Short labels of up to ten characters can double or triple in length, while long paragraphs typically grow by about 30%. German, Finnish, and Portuguese commonly run longer than English.
</p>

<h3>Does pseudo-localization replace testing with real translations?</h3>
<p>
    No. It finds engineering problems such as extraction, layout, and concatenation, but it cannot judge translation quality, tone, or cultural fit. Use it to make the code ready, then review real translations in context.
</p>

<h3>Which locale code should a pseudo-locale use?</h3>
<p>
    Use a private-use region such as <code>en-XA</code>, the convention Android uses. Codes from XA to XZ are reserved for private use, so they will never conflict with a real country, and Intl formatters fall back to the base language.
</p>

<h2>The Short Version</h2>

<p>
    Generate an accented, padded, bracketed copy of your source locale, add it to your app in development, and click through your key screens. Then make it a CI check so hardcoded strings never come back. It costs an afternoon and saves you from discovering layout bugs one language at a time.
</p>

<p>
    Once your UI is ready for real languages, <a href="/">Babelize</a> generates the translations directly in your repository and keeps every locale file in sync as your source strings change.
</p>
---
- [All articles](https://www.babelize.co/blogs)