Skip to content
All letters

The Fallback That Never Errors

A site had three languages. Two of them were real. The i18n fallback made sure nobody could tell.

I maintain a small property site that ships in three languages: Russian, English, Chinese. Static pages, a dictionary per locale, a few lines of vanilla JS that walk the DOM and swap text nodes by key. No framework, no build step for the translations. It has worked for months.

Last week I opened the Chinese version of one page and read it properly for the first time, not spot-checking a heading, actually reading it top to bottom, the way the person it was built for would.

Two entire sections were in English.

Not garbled, not missing, not a ??? placeholder. Clean, correct, well-typeset English, sitting inside a page whose navigation, headings and footer were all in Chinese. It had been that way since the Chinese locale was added.

The line

Here is the lookup, more or less as it was:

const v = (I18N[lang] && I18N[lang][k]) || (lang !== 'ru' && I18N.en && I18N.en[k]);

That is a completely ordinary i18n fallback, and I would probably write it again. If a key is missing in the requested locale, fall back to English rather than rendering an empty element or the raw key. It is the polite behavior. Every i18n library ships some version of it.

It is also the reason I could not see the problem.

Three pages were missing between 26 and 36 Chinese keys each. Every single one of those keys had an English value. So the fallback fired 26 to 36 times per page, silently, successfully, and the page rendered, complete, styled, no console noise, no visual gap, nothing in the network tab. The failure had no output.

The keys that were missing were not the small ones. They were the long-form blocks: the programme description, the "why now" argument, the specification rows, the occupied-status notice. Which makes sense in hindsight, the long ones are the ones you skip when you are working through a dictionary by hand and running out of evening.

And the meta tags were missing too, which is the part I would have kept not noticing for a long time. The description and the og:* tags are the ones nobody looks at in a browser. They are also exactly what renders when someone forwards the link inside a chat app, which, for a Chinese-speaking reader, is how the link is going to arrive.

What I actually got wrong

The bug was not the fallback. The bug was that I had no test that could distinguish "translated" from "rendered".

Everything I was doing checked the second thing. I loaded the page, it looked right. I switched the language, the heading changed. I checked for layout breakage in a long locale. All of those pass on a page that is 40% English, because a fallback's entire job is to make that page pass.

The check that finds it is not a visual one and it is not clever:

const base = Object.keys(I18N.ru);
for (const lang of ['en', 'zh']) {
  const missing = base.filter(k => !(k in I18N[lang]));
  if (missing.length) throw new Error(`${lang}: ${missing.length} missing, ${missing.join(', ')}`);
}

Key parity. Twelve lines including the error message. It has nothing to do with rendering, which is why rendering could never have caught it.

The general shape, which I think is the actual lesson and which I keep re-learning in different costumes: a fallback converts a failure into a degraded success, and degraded successes do not get reported. You cannot find them by using the software, because using the software is the thing the fallback was built to keep working. You find them by asserting an invariant one level up from the behavior, here, that two dictionaries have the same key set, a statement that never mentions the page at all.

The one that was worse

While I was in there I found the sibling bug. The concept pages honored a ?lang= query parameter. The hub page, the one every link points at, did not. Its script simply never read the parameter.

So the shareable link for a Chinese reader, the URL with ?lang=zh on the end, the one thing I would paste into a message, landed them on a Russian page. Deep links worked. The front door did not.

Same failure shape, one layer out. Nothing errored. The hub rendered perfectly, in the wrong language, and the parameter I was carefully appending had been decorative the whole time. I had tested the parameter on the pages where I had implemented it.

Both fixes took an afternoon. Finding them took reading my own site as a stranger who does not speak the language it quietly fell back to.

Back to all lettersayal.tech / letters