Hero

Always go the extra mile


I just spent three days hunting down what I thought was a simple internationalization bug.

Let me walk you through this debugging journey. It is not something that will teach you a super niche and clever bug, it is a quite common one but the morale on this story is what counts.

The Easy Part

First, some context. I got assigned a task to add Chinese support to our app.

Adding Simplified Chinese (zh-CN) and Traditional Chinese (zh-TW) was straightforward - just the usual i18n workflow:

  1. Add locales to lingui.config.js
  2. Create the folder structure (locales/zh-CN/, locales/zh-TW/)
  3. Extract and translate strings
  4. Test with browser language settings

Everything worked perfectly. Standard stuff.

Then came the request for Hong Kong support.

The Problem

”Should be just as easy” I thought. Hong Kong users need Chinese too, but they use Traditional script like Taiwan. So I added zh-HK following the same pattern.

I opened the browser and immediately saw the first alarm, it wasn’t working… “What the hell?, well, maybe some classic cache thing, reload server here and there, spin up the app again…” Still not working, “Crap, something is going on, lets go with some console.logs()“

Browser sends: zh-Hans-HK
App expects: zh-HK
Result: Fallback to English

The browser extension I was using to change the locale on the fly was sending zh-Hans-HK (Simplified script) but I’d set up zh-HK expecting Traditional. And why was the browser sending Simplified for Hong Kong anyway?

Turns out browser locale extensions and system settings can send different script variants than you expect. The browser extension was using proper BCP 47 format (zh-Hans-HK = Chinese, Simplified script, Hong Kong region) but our app was using simplified codes (zh-HK).

Just a format mismatch “Ha, got it, some googling always helps”.

The Quick Fix

My first instinct was to add a simple conversion layer:

// Convert browser codes to app codes
const browserToAppLocale = {
  'zh-Hans-HK': 'zh-HK',
  'zh-Hans-CN': 'zh-CN', 
  'zh-Hant-TW': 'zh-TW',
};

locale = browserToAppLocale[locale] || locale;

Six lines. Done. “Chinese users happy”

But then came the code review question from my CTO that changed everything: “Why not just use proper BCP 47 names for the folders and lingui.config instead of this conversions in the code?”

The “Proper” Solution

Fair point. The “right” way would be:

  • Rename folders to match web standards
  • Update config files
  • Embrace BCP 47 throughout

So I did exactly that:

  1. ✅ Renamed zh-HK folder to zh-Hans-HK
  2. ✅ Updated lingui.config.js
  3. ✅ Rebuilt everything
  4. ✅ Tested

Everything looked perfect. But it still fell back to English.

Browser: zh-Hans-HK
Available locales: ['en', 'zh-CN', 'zh-TW', 'zh-HK', ...] // Still zh-HK!

Wait, what? The folder was renamed but the available locales list still showed the old name.

Going Deeper - The extra mile

The conversion layer worked, and even though the review from my CTO was suposed to work, it didn’t, if I were to be working somewhere else, maybe this could have gone through, but my CTO insists to “always go deeper”, this didnt have a tight deadline, and something was clearly happening. So I did what I had to do, I followed all the flow of how locales were being loaded, took some time but… That’s when I discovered the available locales came from webpack scanning folders at build time:

const locales = fs
  .readdirSync(path.resolve(process.cwd(), 'src/shared/locales'))
  .filter((file) => {
    return file.length === 2 || file.length === 5; // 👀 ⚠️
  });

And there it was. The real villain.

The Dragon

This innocent filter was rejecting zh-Hans-HK because it’s 10 characters long, not 2 or 5 !!!.

The logic:

  • en (2 chars) - include
  • zh-CN (5 chars) - include
  • zh-Hans-HK (10 chars) - reject silently

The Real Fix

The actual solution wasn’t about locale conversion at all:

// Old: Hardcoded assumptions
.filter((file) => {
  return file.length === 2 || file.length === 5;
});

// New: Check what we actually want
.filter((file) => {
  const fullPath = path.resolve(process.cwd(), 'src/shared/locales', file);
  return fs.statSync(fullPath).isDirectory();
});

One line change. Problem ACTUALLY solved.

What I Learned

This whole experience taught me some things:

Question legacy assumptions. Check every single line that might be interacting with your changes, dont ever assume that because something has been working perfectly for years, it must still work.

Debug multiple layers. Modern apps have many layers. The bug was in build config, but symptoms appeared at runtime (silently).

Silent failures suck. The webpack filter failed with no errors, no warnings. Just missing locales. These are the worst bugs to hunt.

Final Thoughts

In the end, we went with the BCP 47 approach because it’s standards-compliant. But we also learned to look deeper when “simple” fixes don’t work.

The real lesson?

Sometimes the dragon you’re fighting isn’t the one you think you’re hunting

Mario - 2025 😅

Probably heard that on a movie or something, don’t quote me there eheh


I’d love to hear from you, you can contact me via x

Have you ever found a “simple” bug that turned out to be something completely different? The debugging stories that teach you the most are usually the ones that surprise you