Table of Contents
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:
| Scale | Keys | File Size (gzipped) | Impact |
|---|---|---|---|
| Small app | 50 | ~2 KB | Negligible |
| Medium app | 500 | ~18 KB | Noticeable on 3G |
| Large app | 2,000 | ~70 KB | Significant |
| Enterprise | 5,000+ | ~200 KB | Critical 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
?nsparam = 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 overhead —
useTranslations("pricing")works the same - Backward compatibility —
translations.jsonstill 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):
| Metric | Before (monolithic) | After (namespace split) | Improvement |
|---|---|---|---|
| Translation payload | 478 KB | 12 KB (avg per page) | 97.5% smaller |
| Time to First Byte | 180ms | 45ms | 75% faster |
| Total CDN bandwidth/month | 48 GB | 2.1 GB | 95.6% less |
| Cache hit ratio | 62% | 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:
- Organize keys into namespaces — most apps already do this implicitly (keys like
pricing.titlealready imply apricingnamespace) - Enable namespace-level CDN publishing — your localization platform publishes both formats (full bundle + per-namespace files)
- Update SDK configuration — switch from
loadAlltoloadNamespacesmode - 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:
- Publish per-namespace files alongside your full translation bundle
- Let the SDK load only what's needed based on
useTranslations()calls - 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.