40 Languages, 400KB Bundle — Why I Refuse to Ship Translations in JavaScript

Here's a math exercise that should make you uncomfortable.
You support 40 locales. Each locale has about 300 translation keys. Each key averages 50 characters. That's 40 times 300 times 50. Six hundred kilobytes of raw translation strings.
After JSON encoding and minification, call it 450KB.
Now look at your JavaScript bundle. For mentoring.oakoliver.com, the public bundle is around 400KB. Adding all translations inline would more than double it. And here's the cruel part: every user downloads all 40 locales even though they only need one.
This is the bundle size trap of static internationalization. Libraries like i18next and react-intl encourage you to import translation files at build time. It works fine for 3-5 languages. At 40, it's a performance disaster.
Our solution: load zero translations at build time. The JavaScript bundle contains only English defaults. When the app mounts, it fetches the user's locale translations from a dedicated API endpoint, caches them in memory, and provides them to every component via React context. Subsequent language switches hit the cache.
A 400KB bundle that serves 40 languages. Zero embedded translation overhead.
I – Three Layers of i18n That Don't Step on Each Other
Our internationalization system has three distinct layers. Each handles a different audience.
Layer one is build-time. This is the SEO layer. hreflang tags, title tags, meta descriptions, schema.org structured data — all baked into static HTML at build time. Search engine crawlers see localized metadata without executing a single line of JavaScript. This layer never changes at runtime.
Layer two is the server API. A translation delivery service. One endpoint returns all phrases for a given locale. Another returns the list of available languages with their names and emoji flags. A third accepts a batch of locale codes and returns phrases for all of them in a single response. All served from an in-memory phrases object. No database, no computation.
Layer three is the client application. React context manages locale state, fetches translations on demand, caches them in a map, provides hooks for components to consume phrases, and handles RTL direction switching for Arabic and Hebrew.
The critical insight is that these layers are independent. The SEO layer handles crawlers. The API layer is a dumb pipe. The client layer handles humans. They can evolve separately. Changing a translation doesn't require a build. Adding a locale to the SEO layer doesn't require a client update.
Most i18n architectures conflate these concerns into a single system. Ours keeps them surgically separate.
II – Why an API Instead of Static JSON Files
A common approach is to place translation JSON files in a public directory and fetch them statically. We chose an API endpoint instead.
Single source of truth. The phrases object is imported from a shared module. If a translation changes, the API serves the new version immediately. No build step. No cache invalidation.
Response shaping. The API adds metadata — text direction, emoji flag, language name — alongside the phrases. A static JSON file would need a separate manifest.
The bulk endpoint. When a user opens the language picker, we can preload the top 5 most likely language choices in a single request instead of firing 5 sequential fetches.
Monitoring. API requests are logged. We can see which locales are most requested, detect anomalies, and measure response times. Static files give you access logs at best.
The API isn't doing anything clever. It reads from an in-memory object and returns JSON. But the interface gives us flexibility that static files don't.
III – The Translation Cache That Survives React
Translations are cached in a module-level map that lives outside the React component tree.
Why not React state? Two reasons.
First, persistence across remounts. If the app provider unmounts and remounts — route change, error boundary recovery — a React state cache would be lost. The module-level map survives because it's not tied to any component lifecycle.
Second, shared access. Multiple components can read from the same cache without triggering re-renders during population. React state updates cause re-renders. Map mutations don't.
When the app needs translations for a locale, it checks the cache first. If the translations are there, it returns them synchronously. No network request. No loading state. No delay.
The first language switch is a network fetch. Every subsequent switch to the same language is instant. This means a user who switches from English to Portuguese and back to English pays for one network request total. The second switch to English is a map lookup that takes less than a millisecond.
IV – The Locale Detection Cascade
How does the app know which language to load?
It uses a priority cascade. Five levels, checked in order.
Priority one: the URL path. If the user is on a path that starts with a locale code like "pt-BR," that wins. The URL is explicit intent.
Priority two: the server-injected locale. The HTML template includes a configuration object with the locale. This handles cases where URL parsing might be ambiguous.
Priority three: localStorage. If the user previously selected a language, respect that choice on return visits. This is the "remember me" of i18n.
Priority four: the browser's language. The navigator.language property reflects the user's OS and browser settings. A reasonable default for first-time visitors who arrive without a locale in the URL.
Priority five: English. The universal fallback.
Each priority level is a stronger signal of intent. The URL is the strongest because the user explicitly navigated there. The browser language is the weakest because it's just a system setting. localStorage sits in between — it's a previous explicit choice, but it might be stale.
V – The Merge That Prevents Broken UIs
When translations arrive from the API, the app doesn't just set them directly. It merges them with English defaults.
This is critical. If a translation file is missing a key — the translator hasn't gotten to it yet — the merge fills the gap with the English text. The UI never shows an undefined value. It never shows a raw translation key like LANDING_HERO_TITLE. It shows the English text as a fallback.
Partial translation is better than broken translation. A Portuguese user might see 95% of the UI in Portuguese and 5% in English. That's vastly better than seeing 5% as empty strings or cryptic key names.
We track completeness per locale. Primary markets — English, Portuguese, Spanish — are at 100%. Major European languages are above 90%. Asian languages are 70-90%. Everything else is 50-70%.
Users in partially-translated locales see a mix. This is intentional. We'd rather ship partial support for 40 locales than perfect support for 5.
VI – Language Switching Without Page Reload
When the user selects a new language from the picker, five things happen in a single orchestrated sequence.
The React state updates with the new locale. The choice persists to localStorage for future visits. The URL updates via the history API — no page reload, no navigation. The document direction attribute flips if switching to or from an RTL language. And the translations load — from cache if available, from the API if not.
The URL update is particularly elegant. The user is on the English mentors page. They switch to Portuguese. The URL silently becomes the Portuguese mentors path. No page reload. No network request for new HTML. The React app stays mounted. Only the translations change.
The RTL direction update is immediate. When switching to Arabic or Hebrew, the document direction flips to right-to-left. Combined with Tailwind's RTL utilities, buttons move to the left, text aligns right, navigation mirrors. Switching back reverses it. This happens in the same tick as the locale state update, so there's no visual flash.
The entire language switch — from tap to fully rendered new language — takes less than a millisecond for cached locales and about 50 milliseconds for a first-time fetch.
VII – The English-Default Optimization
Here's a detail that saves the majority of our users a network request.
English defaults are bundled in the JavaScript. The phrases object that serves as the merge base is part of the application code. It's not fetched from the API.
For the roughly 60% of our users who browse in English, there is zero additional network overhead for translations. The defaults are in the bundle. The app mounts, detects English, and uses the bundled phrases directly. No API call.
The API fetch only triggers for non-English locales. This means the translation loading system has zero cost for the majority of users and minimal cost for the rest.
Compare this to static bundling, where every user downloads every language regardless of which one they need. Or to the split-bundle approach, where even English requires a separate chunk to be loaded.
Dynamic loading shifts the cost from "everyone pays for everything" to "each user pays only for what they need." That's the fundamental economic principle behind this architecture.
Scaling your product to global markets? I help engineers design i18n architectures that don't collapse under their own weight. Book a session at mentoring.oakoliver.com, or see how our micro-SaaS marketplace handles multi-locale at vibe.oakoliver.com.
VIII – The Loading State That Nobody Sees
During translation loading, a flag tracks whether translations are in flight. Components could check this flag and show a skeleton. In practice, we don't.
The English defaults render immediately. When the translated phrases arrive — roughly 50 milliseconds later — the text updates. For most users, this transition is imperceptible. The initial render shows English, and the translated text replaces it before the user has time to read the first word.
For RTL languages, there's a brief layout shift when direction changes from left-to-right to right-to-left. We mitigate this by setting the direction from the server-injected locale during the initial render, before translations even load.
The fastest loading state is the one you never show.
IX – Cache Invalidation: The Problem We Don't Have
Our cache never invalidates during a session. And that's fine.
Translations change infrequently. Maybe once a month. The likelihood of a translation changing while a user has the app open is near zero.
The cache is per-session. When the user refreshes or returns later, the module-level map is empty. The next fetch gets the latest translations automatically.
There's no CDN caching. The API response has no cache-control header. Every fetch goes to the Elysia server and gets the current phrases from memory.
If we needed real-time translation updates — say, for A/B testing translated copy — we could add a version field to the API response and check it against the cached version. But for our use case, session-scoped caching is more than sufficient.
The hardest problem in computer science, and we solved it by not needing to solve it.
X – Bulk Preloading: The Language Picker Trick
The bulk API endpoint exists for a specific optimization we haven't fully shipped yet but the API is ready for.
When the user opens the language picker, preload the top 5 most likely locale choices in a single request. Filter out any already in the cache. Send one request. Populate the cache with all 5 responses.
One round-trip instead of 5. The user opens the picker, the top languages silently load in the background, and when they select one — instant switch, zero latency.
This is the kind of optimization you design the API for on day one, even if you ship it on day ninety. The bulk endpoint costs almost nothing to build. But retrofitting it later means changing the API contract.
XI – The Full Request Flow: A Brazilian User
Let me trace the complete journey for a Brazilian user visiting the mentors page.
The server receives the request. The compiler looks up the Portuguese Brazilian HTML template for the mentors route. The data loader fetches mentor profiles from the database. The server injects mentor data into the HTML and serves it. The HTML arrives with Portuguese title tags, meta descriptions, and hreflang tags — that's the static SEO layer doing its job.
The browser loads the JavaScript bundle — 400KB, with English defaults. The entry point reads the injected data and pre-populates React Query's cache with mentor profiles. The app provider mounts, detects Portuguese Brazilian from the URL, and fires a translation fetch to the API.
The API responds with Portuguese phrases — about 12KB of JSON. The context merges them with English defaults, caches the result, and sets the document direction. Components re-render with Portuguese translations.
The user sees mentor cards — from the data injection — with Portuguese UI text — from the translation fetch. Steps 7 through 12 happen within about 100 milliseconds of the JavaScript loading. The user perceives a single, instant page load with Portuguese content.
Three independent systems — data injection, SEO templates, dynamic translations — working together seamlessly. None of them know about each other. Each does one job.
XII – When to Choose Which Approach
Use static bundling when you support 3-5 languages, bundle size isn't a concern, you need offline PWA support, and instant language switching is critical UX.
Use dynamic loading — our approach — when you support 10 or more languages, bundle size matters, you have a fast API server, partial translations are acceptable, and most users stick to one language.
Use server-rendered i18n when every page must render server-side, SEO in all locales is the primary concern, and you're already invested in a framework like Next.js.
Our hybrid approach — static HTML for SEO, dynamic API for runtime — gets the benefits of all three without the tradeoffs of any one.
Never bundle all translations. The math doesn't work at scale. English defaults are your safety net. Cache at the module level, not in React state. The language picker is a preloading opportunity. And always — always — separate SEO internationalization from runtime internationalization.
Crawlers and humans have different needs. Don't try to serve both with one system.
How many languages does your product support, and how much of your bundle is translation strings you could be loading dynamically instead?
– Antonio