DEV Community

Cover image for Building a Lightning-Fast i18n Alternative: Why I Ditched i18next for Native JavaScript
Artyom
Artyom

Posted on

Building a Lightning-Fast i18n Alternative: Why I Ditched i18next for Native JavaScript

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:

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",
});
Enter fullscreen mode Exit fullscreen mode

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]();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`;
  },
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode
// 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];
  };
}
Enter fullscreen mode Exit fullscreen mode
import { tVariable } from "translations/variable";

app.use((req, res, next) => {
  const locale = detectLocale(req);
  tVariable.run(locale, next);
});
Enter fullscreen mode Exit fullscreen mode

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

Live Demo on StackBlitz

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)