Your i18n works perfectly in the language you test in.
The bugs live in the fallback: the place your app goes when a translation is missing and nobody notices.
Apps with daily content (a daily prompt, a daily habit card, a daily puzzle) have an extra twist: every day is new content that must exist in every language, at the right moment, in every time zone. Here are the six places I'd check first, using a fictional habit app called Dailyish as the example.
1. The silent fallback
Most i18n libraries fall back gracefully: if de-AT is missing a key, try de, then the default language. That's good. The problem is that it's silent.
A German-speaking user sees one English button among German ones. Nobody files a bug. You never find out.
Fix: log every fallback in development and staging, and count them in production.
function t(key, locale) { for (const loc of fallbackChain(locale)) { const value = messages[loc]?.[key]; if (value !== undefined) { if (loc !== locale) reportFallback(key, locale, loc); return value; } } reportMissing(key, locale); return key; } A weekly "top missing keys" list is the cheapest translation QA you'll ever have.
2. Plurals are not "one" and "other"
English has two plural forms. Many languages have more. The Unicode CLDR plural rules define categories like zero, one, two, few, many and other, depending on the language.
Don't write count === 1 ? "day" : "days". Use Intl.PluralRules:
const rule = new Intl.PluralRules(locale).select(streak); const label = messages[locale].streak[rule] ?? messages[locale].streak.other; The streak counter is exactly where this bites daily apps, because it's on screen every single day.
3. Whose midnight is "today"?
This is the one unique to daily content. When does the new day start?
- Server time: simple, but users in other time zones get the new item mid-afternoon or mid-night.
- User's local time: feels right, but now two users can be on different "days" simultaneously, which matters if you have shared leaderboards or spoiler-sensitive content.
- A fixed global time: fair for competition, odd for everyone far from that zone.
There's no universal right answer, but pick one on purpose and display dates with Intl.DateTimeFormat using an explicit time zone so the label matches the logic:
new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: userTimeZone }).format(now); The classic bug: the content switches by server time, but the date label uses local time. The user sees "Tuesday" on Monday's card.
4. Text expansion breaks layouts
Translated strings are often noticeably longer or shorter than English. A button that fits "Done" may not fit its translation. Headlines wrap, badges overflow, and your carefully centered card becomes three lines.
Cheap defences:
- Test with a pseudo-locale that pads every string by about 40% and wraps it in brackets.
- Avoid fixed widths on anything containing text.
- Never bake text into images.
5. Daily content must exist in every language before it goes live
For static UI strings, a fallback to the default language is annoying. For the daily content itself, it's a broken product: half the users get today's item in a language they didn't choose.
Treat it like a deploy gate:
- The item for day N is "publishable" only when every supported locale has it.
- A scheduled job checks a few days ahead and alerts if anything is missing.
- If an item isn't ready in a locale, decide the policy in advance: hold for everyone, or show an evergreen backup item.
6. Search engines need to know the versions are related
If each language lives on its own URL, tell search engines with hreflang annotations. Google's guide to localized versions covers the formats. Common mistakes: forgetting the self-reference, forgetting the return links, and pointing hreflang at pages that redirect.
A pre-launch checklist
- [ ] Fallbacks are logged and counted
- [ ] Plurals use Intl.PluralRules with every category your locales need
- [ ] One documented rule for when "today" starts
- [ ] Date labels use the same time zone as the content switch
- [ ] Pseudo-locale layout test passed
- [ ] Daily content gated on all locales being ready
- [ ] hreflang with self-references and return links
Testing it in CI
Most of these checks can run automatically on every pull request. A minimal version compares every locale's keys with the default locale and fails the build if anything is missing:
const base = Object.keys(messages.en); for (const [locale, dict] of Object.entries(messages)) { const missing = base.filter((key) => !(key in dict)); if (missing.length) { console.error(locale + " is missing: " + missing.join(", ")); process.exitCode = 1; } } Extend it over time: check that every plural category a locale needs is present, that interpolation placeholders match across languages, and that the next few days of daily content exist in every locale. Each check is a few lines, and each one replaces a bug report you would otherwise never receive.
Takeaways
- Fallbacks are a feature and a bug hider. Log them.
- Use CLDR plural categories, not ternaries.
- Decide whose midnight starts the day, and make labels follow the same rule.
- Test layouts with padded pseudo-strings.
- Gate daily content on every locale, with a plan for when one is late.
Your turn
What's the sneakiest i18n bug you've shipped? Bonus points if it only appeared in one time zone.
I write about practice, feedback and the AI-readable web while building NEXO, a daily learning game.
