Engineering//12 min read

How to Split Large Translation Files: Namespace-Level Loading for Faster Apps

Ali Osman Delismen
Share

Your translation files are silently killing your page load times. A typical internationalized app with 500+ keys generates translation bundles of 400-800KB per language. Multiply that by 15 languages, and you're serving 6-12MB of translations — most of which users never see.

This guide covers why this happens, how namespace-level splitting solves it, and the practical implementation patterns that reduce translation payloads by 90-97%.

The Hidden Cost of Monolithic Translation Files

Most i18n setups start simple: one translations.json file per locale. It works fine at 50 keys. But production apps grow fast:

ScaleKeysFile Size (gzipped)Impact
Small app50~2 KBNegligible
Medium app500~18 KBNoticeable on 3G
Large app2,000~70 KBSignificant
Enterprise5,000+~200 KBCritical bottleneck

The problem compounds with multiple languages. A 500-key app in 15 languages means the CDN serves 270KB of translation data before the user sees any content — and they only need translations for the current page (typically 20-50 keys, ~2KB).

Real-world example: Our own landing page at Better i18n grew to 699 keys across 59 namespaces. The English translations.json was 478KB. The Hindi translation was 841KB. Every visitor downloaded the entire file, even if they only visited the pricing page (14 keys, ~2KB needed).

What Are i18n Namespaces?

Namespaces are logical groups within your translations — typically organized by page, feature, or domain:

{
  "hero": {
    "title": "Ship translations faster",
    "subtitle": "AI-powered localization for modern apps"
  },
  "pricing": {
    "title": "Simple, transparent pricing",
    "monthly": "Monthly",
    "yearly": "Yearly"
  },
  "footer": {
    "copyright": "© 2026 Acme Inc.",
    "privacy": "Privacy Policy"
  }
}

Libraries like i18next, react-intl, and Better i18n all support namespaces. The typical usage is useTranslations("pricing") — you already tell the SDK which namespace you need. The SDK just doesn't use that information for loading optimization.

Three Strategies for Splitting Translation Files

Strategy 1: Per-Namespace CDN Files

Instead of one monolithic file, publish each namespace as a separate file:

Before:
  /en/translations.json     → 478 KB (all 59 namespaces)

After:
  /en/translations.json     → 478 KB (backward compat)
  /en/hero.json             → 1.2 KB
  /en/pricing.json          → 2.1 KB
  /en/footer.json           → 0.8 KB
  ...59 individual files

When a page needs hero + pricing + footer:

  • Before: 1 request × 478 KB = 478 KB
  • After: 3 requests × ~1.4 KB avg = 4.1 KB (99% reduction)

The tradeoff is more HTTP requests. But with HTTP/2 multiplexing, 3-6 small parallel requests complete faster than 1 large request. And each namespace file caches independently — updating pricing doesn't invalidate hero.

Strategy 2: Namespace Query Parameter

Keep the single-file CDN structure but add server-side filtering:

GET /en/translations.json?ns=hero,pricing,footer
← Returns only requested namespaces (~4 KB)

Advantages:

  • Single HTTP request (no waterfall)
  • No R2/S3 storage overhead (files aren't duplicated)
  • Backward compatible (no ?ns param = full file)

Disadvantages:

  • Requires CDN worker logic (can't serve static files)
  • Cache key complexity (each namespace combination is different)
  • Harder to cache at edge (query params reduce cache hit ratio)

Strategy 3: Build-Time Code Splitting

Frameworks like Next.js and Vite can split translations at build time:

// next-i18next with dynamic imports
const i18nConfig = {
  ns: ['common', 'pricing'],
  resourceLoader: (language, namespace) =>
    import(`./locales/${language}/${namespace}.json`),
};

This bundles only the translations that each page imports. Webpack/Vite creates separate chunks per namespace.

Advantages:

  • Zero runtime overhead (resolved at build)
  • Works with SSR/SSG (translations included in server bundle)
  • Tree-shaking eliminates unused keys

Disadvantages:

  • Only works with file-based translations (not CDN delivery)
  • Requires rebuild to update translations
  • Doesn't help with OTA (over-the-air) translation updates

The Optimal Architecture: CDN + Smart Loading

The best approach combines CDN-level namespace files with SDK-level intelligent loading:

CDN publishes:
├── /en/translations.json     ← full bundle (backward compat)
├── /en/hero.json             ← per-namespace
├── /en/pricing.json
├── /en/footer.json
└── /manifest.json            ← includes namespace list + URLs

SDK behavior:
1. Fetch manifest.json (cached, small)
2. Detect which namespaces current page needs
3. Batch-fetch only those namespace files
4. Cache each namespace independently
5. On navigation, fetch missing namespaces incrementally

This gives you:

  • Minimal initial payload — only what the current page needs
  • Fast navigation — shared namespaces (header, footer) already cached
  • Independent cache invalidation — updating one namespace doesn't bust others
  • Zero developer overheaduseTranslations("pricing") works the same
  • Backward compatibilitytranslations.json still serves the full bundle

Implementation: Namespace-Level CDN with Better i18n

Here's how to set up namespace-level loading in a React + Vite app:

1. Configure Namespaces in Your Project

Organize your translation keys with clear namespace boundaries:

Keys:
  hero.title          → namespace: "hero"
  hero.subtitle       → namespace: "hero"
  pricing.title       → namespace: "pricing"
  pricing.monthly     → namespace: "pricing"
  footer.copyright    → namespace: "footer"

2. SDK Automatically Resolves Namespaces

When you call useTranslations("pricing"), the SDK knows it needs the pricing namespace. If that namespace isn't loaded yet, it fetches /en/pricing.json from the CDN.

function PricingPage() {
  // SDK fetches /en/pricing.json (~2KB) — not the full 478KB bundle
  const t = useTranslations("pricing");
  
  return (
    <div>
      <h1>{t("title")}</h1>
      <span>{t("monthly")}</span>
    </div>
  );
}

3. SSR Optimization with Vite Plugin

For server-rendered pages, the Vite plugin pre-loads the right namespaces at build time and injects only what the current route needs:

<!-- Before: entire 478KB bundle injected into every page -->
<script id="__i18n__" type="application/json">
  {"hero":{"title":"..."},"pricing":{"title":"..."},...59 more namespaces}
</script>

<!-- After: only 3 namespaces for this page (~4KB) -->
<script id="__i18n__" type="application/json">
  {"hero":{"title":"..."},"header":{"nav":"..."},"footer":{"copyright":"..."}}
</script>

Performance Benchmarks

We measured the impact of namespace splitting on our own landing page (699 keys, 59 namespaces, 15 languages):

MetricBefore (monolithic)After (namespace split)Improvement
Translation payload478 KB12 KB (avg per page)97.5% smaller
Time to First Byte180ms45ms75% faster
Total CDN bandwidth/month48 GB2.1 GB95.6% less
Cache hit ratio62%94%+32 points

The cache hit ratio improvement comes from granular caching — when you update pricing copy, only /en/pricing.json gets invalidated. The other 58 namespace files remain cached at the edge.

When NOT to Split

Namespace splitting isn't always the right choice:

  • Small apps (< 100 keys): Gzipped translations are ~4KB. Splitting adds complexity without meaningful benefit.
  • Static sites with SSG: If translations are bundled at build time and served as static HTML, there's no runtime payload to optimize.
  • Single-page apps with preloading: If your SPA preloads all translations during the splash screen, splitting doesn't improve perceived performance.

Rule of thumb: If your translation file exceeds 50KB gzipped (roughly 1,000+ keys), namespace splitting will meaningfully improve load times.

Migration Path: Monolithic → Namespace-Level

You don't need to rewrite your app. The migration is incremental:

  1. Organize keys into namespaces — most apps already do this implicitly (keys like pricing.title already imply a pricing namespace)
  2. Enable namespace-level CDN publishing — your localization platform publishes both formats (full bundle + per-namespace files)
  3. Update SDK configuration — switch from loadAll to loadNamespaces mode
  4. Measure — compare payload sizes and cache hit ratios before/after

The key insight: your useTranslations("namespace") calls already declare which namespaces each page needs. The infrastructure just needs to use that information at the CDN and loading layer — not at the component layer.

Conclusion

Large translation files are a solvable performance problem. The architecture is straightforward:

  1. Publish per-namespace files alongside your full translation bundle
  2. Let the SDK load only what's needed based on useTranslations() calls
  3. Cache independently so updates to one namespace don't invalidate others

This approach works with any i18n library that supports namespaces (i18next, react-intl, Better i18n, LinguiJS). The CDN-level change is universal — your app code stays the same.

For apps serving 15+ languages with 500+ keys, this single change can reduce translation payloads by 90-97% and cut CDN bandwidth by 95%. That's better Core Web Vitals, faster time-to-interactive, and happier users worldwide.

Comments

Loading comments...