The Performance Crisis in Modern i18n
If you're using i18next with TypeScript, you've probably felt the pain. Despite performance improvements, the reality is sobering:
tested on Apple M1
-
TypeScript compilation: Each 1,000 translation keys adds ~1 second to
tsc
build time - IDE responsiveness: Type hints slow down by 0.3+ seconds with large dictionaries
- Bundle size: i18next weighs 41.6 kB (13.2 kB gzip) before you even add translations
- Runtime performance: Custom DSL parsing becomes a bottleneck at scale
Real developers are feeling this pain:
"We had to remove i18n typing entirely due to CI memory overflow with ~3k translations" - Production developer
"Removing i18next improved our SSR performance by 3x without losing functionality" - Performance engineer
But here's the thing: modern JavaScript has everything we need built-in.
Why Go Native?
The Internationalization API has matured significantly. We have:
-
Intl.NumberFormat
for numbers, currencies, units -
Intl.DateTimeFormat
for dates and times -
Intl.PluralRules
for pluralization logic -
Intl.RelativeTimeFormat
for "2 days ago" formatting
These APIs are zero-cost, tree-shakeable, and blazing fast.
The Solution: A 5-File i18n System
Here's a complete internationalization system that's simpler, faster, and more maintainable than traditional libraries:
1. Language Detection & Management
// translations/lang.ts
import { cookie } from "../cookie";
const LANGS = {
en: "en",
ru: "ru",
} as const;
export type LANGS = keyof typeof LANGS;
const userLang = cookie.get("lang") ?? window.navigator.language;
export const LANG = userLang in LANGS ? (userLang as LANGS) : "en";
export const changeLang = (lang: string) => {
cookie.set("lang", lang);
window.location.reload();
};
// Native formatters - zero overhead
export const degree = new Intl.NumberFormat(LANG, {
style: "unit",
unit: "degree",
unitDisplay: "long",
});
2. Dynamic Translation Loading
// translations/index.ts
import { LANG } from "./lang";
export * from "./lang";
const vocabModule = {
en: () => import("./en"),
ru: () => import("./ru"),
} as const;
// Only load the language we need
export const { vocab: t } = await vocabModule[LANG]();
3. Type-Safe Translation Files
// translations/en.ts
import { degree } from "./lang";
export const vocab = {
hi: "Hello",
temperature: (n: number) => `Temperature is ${degree.format(n)}`,
};
export type Vocab = typeof vocab;
4. Simple Cookie Utility
// cookie.ts
const getCookieRec = () =>
Object.fromEntries(document.cookie.split('; ').map((rec) => rec.split('=')));
export const cookie = {
get(name: string): string | undefined {
return getCookieRec()[name];
},
set(name: string, value: string) {
document.cookie = `${name}=${value}`;
},
};
5. Usage in Components
// App.tsx
import { useState } from 'react';
import { LANG, changeLang, t } from './translations';
export function App() {
const [count, setCount] = useState(0);
return (
<main>
<p>{t.hi}</p>
<p>
<button onClick={() => setCount((s) => s + 1)}>{count}</button>
</p>
<p>{t.temperature(count)}</p>
<p>
<select value={LANG} onChange={(e) => changeLang(e.target.value)}>
{['ru', 'en'].map((lang) => (
<option key={lang} value={lang}>{lang}</option>
))}
</select>
</p>
</main>
);
}
The Benefits
✅ Blazing Fast Types: Direct object access, no complex mapping.
✅ Zero Runtime Overhead: No DSL parsing, no library weight.
✅ Automatic Code Splitting: Only load translations you need
✅ Full Type Safety: TypeScript infers everything automatically
✅ Native Formatting: Leverage browser APIs for numbers, dates, plurals
✅ Simple API: t.key
instead of t('key')
✅ SSR out of the box: no additional setup for SSR
✅ Framework agnostic: use with Svelte, React, Vue or jQuery 😁
Advanced Patterns
Namespace Support
Create subdirectories for different feature areas:
const vocabModule = {
en: () => import("./en"),
ru: () => import("./ru"),
} as const;
const authModule = {
en: () => import("./auth/en"),
ru: () => import("./auth/ru"),
} as const;
Pluralization with Intl.PluralRules
For complex plural forms, integrate the native Intl.PluralRules API directly into your vocabulary:
// translations/en.ts
import { degree } from "./lang";
const pluralRules = new Intl.PluralRules('en-US');
const createPlural = (forms: Record<Intl.LDMLPluralRule, string>) =>
(count: number) => {
const rule = pluralRules.select(count);
return forms[rule].replace('{count}', count.toString());
};
export const vocab = {
hi: "Hello",
temperature: (n: number) => `Temperature is ${degree.format(n)}`,
items: createPlural({
zero: 'No items',
one: '1 item',
two: '{count} items', // for languages that distinguish "two"
few: '{count} items', // for languages with "few" category
many: '{count} items', // for languages with "many" category
other: '{count} items'
}),
// More complex example with gender/case variations
notifications: createPlural({
zero: 'No new notifications',
one: 'You have 1 new notification',
other: 'You have {count} new notifications'
})
};
export type Vocab = typeof vocab;
Usage remains beautifully simple:
<p>{t.items(0)}</p> // "No items"
<p>{t.items(1)}</p> // "1 item"
<p>{t.items(5)}</p> // "5 items"
The beauty is that each language can define its own plural rules - Russian has different categories than English, and the Intl.PluralRules
API handles all the complexity for you.
Server-Side Rendering
One of the biggest advantages of this approach becomes apparent with Server-Side Rendering. It just works out of the box - no complex server configuration, no hydration mismatches, no locale detection headaches.
For serverless environments (Lambda, Vercel Functions, etc.), this solution is perfect as-is. Each request gets its own execution context, so the static imports work beautifully.
For stateful servers (Express, Fastify, etc.), you have a simple migration path. Convert the dot notation t.key
to function calls t().key
, then implement the t
function using Node.js's AsyncLocalStorage
:
// translations/variable.ts
import { AsyncLocalStorage } from "async_hooks";
export const tVariable = new AsyncLocalStorage<string>();
// translations/index.ts
// ...
const { vocab } = await vocabModule[LANG]();
export let t = () => vocab;
if (SSR) {
const vocabs = {
en: import("./en"),
ru: import("./ru"),
} as const;
const { tVariable } = await import("./variable");
t = () => {
const locale = tVariable.getStore() ?? "en";
return vocabs[locale];
};
}
import { tVariable } from "translations/variable";
app.use((req, res, next) => {
const locale = detectLocale(req);
tVariable.run(locale, next);
});
This gives you per-request locale isolation without any global state pollution - exactly what you need for concurrent request handling.
The Trade-off
The main downside: Translations live in code, making it harder for non-technical team members to edit them. This isn't always a problem - many teams prefer developer-controlled translations for better version control and review processes.
For teams that need non-technical editing, consider:
- Build-time generation from external sources
- Git-based workflows with translation management tools
- Hybrid approaches for different content types
Try It Out
This approach has transformed how I think about internationalization. Sometimes the best solution isn't the most popular one - it's the one that leverages what's already built into the platform.
What's your experience with i18n performance? Have you found other lightweight alternatives? Share your thoughts in the comments!
Top comments (0)