Skip to content
OSOKORO

What shipping in three languages actually cost us

153 KB of gzipped catalogue on every route, a tree-shaking assumption that was wrong, and the four i18n failures that pass every test.

Published Aug 22, 20265 minutes to read

We translated four product sites into Spanish and Brazilian Portuguese. This is what it cost, measured rather than estimated, including the part we got wrong and did not notice for weeks.

The measurement

Every visitor to any page on this site downloads all three languages of all six message groups.

The shared chunk is 481 KB raw, 153 KB gzipped, and it appears in firstLoadChunkPaths for all 60 routes. The non-English catalogues on their own — every groups/*.es.ts and groups/*.pt-BR.ts — are 384 KB of source, 352 KB with comments stripped, somewhere in the region of 80–110 KB gzipped.

So an English speaker reading the pricing page downloads the Spanish and Portuguese pricing page too. And the dashboard strings. And the email templates.

The assumption that was wrong

The catalogue loader is about as simple as it gets:

const CATALOGUES: Record<Locale, Messages> = { en, es, "pt-BR": ptBR };

export function messages(locale: Locale): Messages {
  return CATALOGUES[locale];
}

The comment above it argued for static imports over lazy ones on three grounds: the catalogues are small, they are needed during server rendering where there is no loading state to show, and a dynamic import would push await into components that have no other reason to be async. All three still hold.

It also said: "Static also means the bundler can tree-shake a catalogue that a given route never references."

That part is false, and it is false for a reason that is obvious once you look at it. CATALOGUES is indexed by a runtime value. The bundler cannot know which key messages(locale) will read, so it retains all three. There is no analysis that recovers this; it is not a limitation of a particular bundler.

The claim sat in a comment for weeks, sounding reasonable, and nothing contradicted it because nothing measures bundle size in CI. There is no size budget, no check, no failing build. The number was only found by going and looking at .next/diagnostics/route-bundle-stats.json.

That is the transferable lesson: an assumption written in a comment, in a codebase with no guard for the thing it asserts, is indistinguishable from a fact until somebody measures it.

Why it has not been fixed

Because the fixes are worse, and it is worth being explicit about why rather than filing it as debt.

Dynamic import per locale makes every message lookup async. That pushes await into components that are otherwise synchronous, including client components, and it introduces a loading state for text — which is the one thing on a page that should never arrive late.

Splitting by group helps and is the most plausible option. It also means every page has to declare which groups it needs, which is a new invariant with no test behind it, and the failure mode is a missing string in production rather than a build error.

Server-only catalogues would work if every consumer were a server component. They are not: theme-toggle.tsx, language-switcher.tsx and site-header.tsx are client components calling messages() directly, and that is what pulls the whole thing into the browser bundle.

The honest position is that 153 KB gzipped on a marketing site is a real cost, it is not the largest cost on the page, and the available fixes each trade it for a correctness risk. It is written down so the next person does not rediscover it, and so the tree-shaking sentence does not get believed again.

Four failures that pass every test

Bundle size was the measurable problem. These were the ones that shipped working software that was wrong.

A locale that could not serve its own language. /en/* is meant to redirect to the unprefixed path, since English is the default and has no prefix. For a while it did that on the apex and not on one of the product subdomains — so /en/pricing worked in three places and 404'd in the fourth. The symptom looks like a missing translation rather than a routing bug, which is why it survived.

A canonical tag pointing at the wrong language. A Spanish page whose canonical names the English URL is telling a crawler "I am a duplicate of that one" — and the entire translated tree drops out of the index while every hreflang on the page still looks correct. The first version of our alternates helper did exactly this, and the tests asserting the hreflang values all passed while it did.

The home page's share card. Next serves file-based metadata beside the page it belongs to, so /docs/opengraph-image-<hash> sits under /docs and matches a route rule. For the root page the same file becomes a top-level segment — /opengraph-image-<hash> — which matched no rule, was treated as opaque, and answered 404. Measured in production: three subdomains each served a home page whose og:image nobody could fetch, while the identical card on /docs returned a 41 KB PNG. Every share card looks fine until somebody pastes the home page, which is the URL people actually share.

A translation that was never translated. A string copied from English into the Spanish catalogue is a perfectly valid string. tsc is satisfied, every test passes, and the page renders. The only thing that catches it is a test asserting that translated values differ from English, with an explicit allowlist for the ones that legitimately match — "Blog" is the same word in all three languages, and so is a XXXX-XXXX placeholder.

What we would keep

The guards, mostly. Three of the four failures above are now unit tests rather than lore:

  • Every advertised hreflang is checked against what the router actually serves, in both directions.
  • Catalogue parity checks keys, types, argument counts, and that no value was left in English.
  • A vitest scan fails the build on any JSX text node of five or more sentence-shaped words outside the catalogue, with a file-level allowlist where every entry carries a written justification.

And one thing we would do differently: measure the bundle in CI from the start. Not because 153 KB would have changed the design — it probably would not have — but because the number would have been a fact from day one rather than a discovery, and the comment claiming tree-shaking would never have been written.

Read next

All writing