DEV Community

Avery Lin
Avery Lin

Posted on

The Umlauts Were Fine, the Word Length Wasn't: A Pseudo-Localization Bug Hunt

The bug report was one line and one screenshot: "Settings page broken in German." The screenshot showed our carefully aligned settings form with the right-hand column shoved halfway off the card. The culprit turned out to be a single string: where English said "Save", German said "Einstellungen speichern" in a label nearby, and a nineteen-character compound noun had no intention of wrapping inside a fixed-width flex column.

Nothing was wrong with the translation. Nothing was wrong with the German language's entirely reasonable habit of welding nouns together. What was wrong was our process: we had shipped a UI that had only ever been rendered in English, and we found out from a user. This post is the debugging record of how I fixed that class of bug for good — not with more discipline, but with a pseudo-localization harness, an expansion budget, and a batch job that lets a model sort thousands of flagged strings down to the few dozen a human actually needs to look at. The model compute and the always-on box it ran on cost nothing, which I'll get to.

Pseudo-localization, or breaking your own app on purpose

The trick is older than LLMs by decades: before you pay translators, you generate a fake locale that is deliberately hostile. Every vowel gets accented (so missing Unicode support shows up as tofu boxes), and every string gets padded by 30–40% (so length-sensitive layouts break immediately). You switch the app to this locale, click through the screens, and every overflow, truncation, and encoding bug announces itself while the fixes are still cheap.

Here's the transform I used, as a small Node script that walks our locale JSON:

// pseudo.mjs — writes locales/de-x-pseudo.json from locales/en.json
import { readFileSync, writeFileSync } from "node:fs";

const ACCENTS = { a: "á", e: "ë", i: "ï", o: "ö", u: "ü",
                  A: "Á", E: "Ë", I: "Ï", O: "Ö", U: "Ü" };

function pseudo(str, key) {
  // Don't touch strings the user never reads as prose.
  if (/^(route|icon|css|aria-keyshortcut)/.test(key)) return str;
  const accented = [...str].map((c) => ACCENTS[c] ?? c).join("");
  // ~35% length inflation, mimicking German/Finnish expansion
  const pad = "~".repeat(Math.ceil(str.length * 0.35));
  return `[${accented}${pad}]`;
}

function walk(obj, prefix = "") {
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) => {
      const key = prefix + k;
      return [k, typeof v === "object" ? walk(v, key + ".") : pseudo(v, key)];
    })
  );
}

const en = JSON.parse(readFileSync("locales/en.json", "utf8"));
writeFileSync("locales/de-x-pseudo.json", JSON.stringify(walk(en), null, 2));
Enter fullscreen mode Exit fullscreen mode

The brackets are deliberate: if a string ever renders without its surrounding [ ], it didn't come through the i18n system at all — it's hardcoded. That one convention caught eleven hardcoded strings in our settings pages alone, including a toast message someone had inlined in a fetch handler in 2023.

The part that doesn't scale: judging the breakage

Clicking through the app in the pseudo-locale produced a pile of failures, but they were not equal. I dumped every rendered string plus a flag for whether it visually misbehaved into a report, and the raw count was 2,300+ strings. Most were fine. The failures fell into buckets that a screenshot diff can detect but can't judge:

  • Cosmetic truncation — a tooltip clipped with ellipsis, which is arguably the design working as intended.
  • Real breakage — text overlapping a button, a column pushed outside its card, a table header wrapping into four lines and covering row one.
  • False alarms — strings flagged by my length heuristic that never render anywhere a user sees (enum values, analytics event names that someone had routed through the translation files for historical reasons).

A human can classify a flagged string in about twenty seconds: is it user-facing, is the truncation acceptable, does this layout need a fix? Twenty seconds times 2,300 is most of a working day, and this report regenerates every time someone adds a feature. That classification pass is where I brought in a model.

I ran the batch through MonkeyCode, which offers free access to coding models and a free server option — the server mattered here because the classification job is a long-running batch, and I wanted it on a box that wasn't my laptop, re-runnable whenever the locale files changed, without a meter ticking during prompt iteration.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

I made two deliberate assumptions-avoidances: I didn't depend on any specific model being available, and I didn't assume free access is permanent. The job hits one configurable endpoint, and the classification is advisory — if the endpoint vanishes, the worst case is I go back to reading the report myself.

The batch classifier

The job feeds the model one flagged string at a time with its context — the translation key, the English source, where in the component tree it's rendered, and what my screenshot diff observed — and demands a small JSON verdict:

// classify.mjs — one verdict per flagged string; run as a batch job
const PROMPT = `You are triaging UI strings flagged during pseudo-localization
testing (strings artificially lengthened ~35% to simulate German/Finnish).
Given the context, reply with ONLY JSON:
{"user_facing": bool,
 "severity": "breakage" | "cosmetic" | "false_alarm",
 "suggested_fix": "wrap" | "truncate" | "widen" | "shrink-source" | "none",
 "reason": string, max 15 words}
Never invent component names. If context is insufficient, use "false_alarm"
and say why in reason.

Key: {key}
English source: {source}
Rendered in: {component}
Observed: {observation}`;

export async function classify(item) {
  const res = await fetch(process.env.I18N_MODEL_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      prompt: PROMPT.replace(/\{(\w+)\}/g, (_, k) => item[k] ?? "unknown"),
    }),
  });
  const { text } = await res.json();
  const json = text.match(/\{.*\}/s)?.[0];
  if (!json) return { severity: "false_alarm", reason: "unparseable output" };
  return JSON.parse(json);
}
Enter fullscreen mode Exit fullscreen mode

The conservative fallback is intentional: an unparseable verdict becomes a "false_alarm," and anything the model calls breakage gets bucketed for human eyes regardless. I would rather read fifty extra strings than have the classifier silently swallow a real overlap.

What the classification actually found

After the batch ran over the full report, the picture was:

Verdict Count Human action
false_alarm 1,412 spot-checked 30, all correct
cosmetic 641 sampled 40; 36 acceptable ellipsis, 4 re-judged real
breakage 214 read every single one

Of the 214 "breakage" verdicts, 61 were genuine layout defects — the German-settings-page class of bug. The rest were borderline: technically overlapping, but in views behind feature flags or admin-only screens, which we fixed anyway because flagged screens get un-flagged eventually.

The verdict I trusted least was suggested_fix. "Widen" versus "wrap" is a design decision that depends on what else lives in the layout, and the model sees one string at a time. I treated the fix column as a conversation starter in the PR description, nothing more. The verdict I trusted most was user_facing: given a translation key like settings.billing.plan.rename.cta and a component path, judging whether an end user reads this string is close to reading comprehension, and the model's false-alarm bucket survived every spot check I threw at it.

Where this approach bends or breaks

  • Pseudo-localization simulates length, not grammar. German also moves verbs to the end of sentences and capitalizes nouns; neither affects layout, but if your strings are concatenated sentence fragments ("Your plan" + "will renew on" + date), length testing won't save you — you have a grammar bug that only real translation review catches.
  • The classifier inherits my instrumentation's blind spots. Strings rendered inside canvas elements, PDF generation, and email templates never made it into the report, so there was nothing to classify. Two of our actual German bugs lived in invoice PDFs.
  • Thirty-five percent is a guess. Finnish and German can exceed it on short strings; CJK locales shrink and expose a different failure mode (touch targets that collapse). I now run a second pseudo-locale at 60% inflation for short strings only.
  • Free infrastructure is for finding out, not for depending on. The batch job is a nice-to-have that regenerates a report; nothing in the product build touches it. Keep anything free at that arm's length.

If your app has no fixed-width layouts, or you're already running visual regression tests across real translated locales every sprint, this adds little. And if your strings are user-generated rather than translated, pseudo-localization is the wrong tool entirely — you want property-based testing with adversarial input length instead.

The settings page, revisited

The original bug's fix was six lines of CSS (min-width: 0 on the flex child, overflow-wrap: anywhere on the label) plus a new rule in our component guidelines: no fixed pixel widths on text containers, period. The harness now runs nightly against the pseudo-locale, the classifier re-sorts the report, and a human reads only the breakage bucket — usually empty, occasionally not.

If you've shipped to a language longer than English, I'd genuinely like to hear your worst one: the string, the language, and what it broke. Mine was a settings label. I suspect someone reading this has a compound noun that took down an entire checkout flow, and those stories deserve daylight. If you want to try the harness, MonkeyCode's free model access and free server are one way to run the batch pass without spending anything — but the transform script alone will already find your hardcoded strings, and that part needs no model at all.

Top comments (0)