Splitting a monolithic catalog into namespaces

One translation.json per locale works well until it does not. The signals are consistent: every page downloads copy for every other page, two teams contend on the same file in every sprint, nobody can say who should review a catalogue diff, and the orphan report is a single list that nobody owns.

Splitting the catalogue fixes all four, and doing it badly loses translations. This page covers choosing the axis, performing the split incrementally, and the two mistakes that make it destructive.

Four properties improved by namespacing Before the split every page downloads the entire catalogue, review has no natural owner, every catalogue change contends on one file, and orphan analysis produces a single undifferentiated list. After the split a page loads two namespaces, the owning team reviews its own copy, conflicts spread across files, and orphan reports are attributable. What the split changes Before After Bundle per page the whole catalogue two namespaces Review everyone or nobody the owning team Merge conflicts one hot file spread by surface Orphan analysis one flat set per namespace
Only the first row is about performance; the other three are about who can act on what.

Root cause: one file is one owner and one loading unit

A catalogue file is simultaneously three things — a unit of review, a unit of loading, and a unit of merge contention. While the product is small all three want the same granularity, so one file is correct. As the product grows they diverge, and one file becomes wrong for all three reasons at once.

The loading consequence is the most measurable. A single catalogue means a reader on the login page downloads the checkout copy, the settings copy and the admin copy, in their locale, before the page is interactive. On a large product that is tens of kilobytes of text that will never render.

The review consequence is subtler and matters more. A file that everyone edits is a file that nobody owns, so catalogue diffs get approved on the strength of the code changes around them. That is exactly when the naming and context conventions from string catalog governance stop being applied.

Four candidate axes for splitting a catalogue Splitting by owning team makes each file reviewable by the people responsible for its copy. Splitting by route makes each page load only what it renders. Splitting by file size produces namespaces nobody owns. Splitting by locale is already how catalogues are stored and is not an additional axis. Which axis should the split follow? One catalogue, four thousand keys every page loads all of it By owning team review becomes meaningful By route loading becomes proportional By file size neither — avoid By locale only already true; not a split
The first two are both right, and they usually coincide — a team normally owns a set of routes.

Choosing the axis

Two axes are defensible and they usually agree. Splitting by owning team optimises review: each namespace has someone whose job it is to care about its copy. Splitting by route optimises loading: each page requests only what it renders. In most products a team owns a coherent set of routes, so a single split satisfies both.

Where they disagree — a shared component rendered on routes owned by three teams — the tiebreaker is ownership, with the shared strings extracted into a common namespace that has an explicit owner of its own. A shared namespace without an owner is where every ambiguous string eventually lands.

The axis that consistently fails is file size. Splitting a four-thousand-key file into four thousand-key files produces namespaces drawn along an arbitrary boundary, which means no team recognises them, review has no home, and the loading benefit is accidental. It is worth saying explicitly because it is the split a script would do, and therefore the split that gets proposed first.

Performing the split without a flag day

The destructive version is a single commit that creates every namespace, moves every key and repoints every call site. It is unreviewable, and if anything is wrong the revert is as large as the change.

The incremental version keeps the old catalogue loaded throughout, so no intermediate state can produce a missing string.

An incremental catalogue split The new namespace files are created empty while the resolver loads both old and new. One surface at a time, keys are moved across every locale together and the surface is repointed in the same commit. The process repeats until the original file is empty, at which point it and its fallback entry are deleted. Splitting without a flag day 1 Add the new namespace files empty resolver loads both, old first 2 Move one surface, all locales together keys move, values unchanged 3 Point that surface at the new namespace same commit as the move 4 Repeat until the old file is empty one reviewable commit each 5 Delete the empty original and the fallback entry with it
At no point is the application in a state where a string could be missing — the old file is still loaded.

The mechanism is a resolver that consults the new namespace first and falls back to the original. Most libraries express that as an ordered namespace list, which means the split needs no special support:

i18next.init({
  ns: ['checkout', 'common', 'translation'],  // 'translation' is the old monolith
  defaultNS: 'checkout',
  fallbackNS: 'translation',                  // anything not yet moved still resolves
});

With that in place, each surface migrates in its own pull request: the keys move across every locale in one commit, the call sites in that surface start naming the new namespace, and the fallback covers everything not yet touched. When the original file is empty, both it and the fallbackNS entry are deleted in a final small commit.

The two mistakes that lose translations

Copying instead of moving. Duplicating keys into the new namespace and leaving them in the original produces exactly the ambiguity described in duplicate keys across namespaces, and the resolver silently picks by order. Worse, the two copies then drift, because translators updating one have no reason to know about the other.

Moving the source but not the targets. If en is split and de, fr and ja are not, the synchronisation job sees a source catalogue whose keys have no counterpart in any target. Depending on the tool this appears as several hundred deleted units, several hundred new untranslated units, or both — and the archival that follows is the same loss described in safely renaming a translation key, multiplied by a surface.

Both mistakes are prevented by one rule: a key’s move is a single commit that touches every locale, and nothing else.

Verification

Three assertions confirm a split is complete and correct: the key set is unchanged in total, no key exists in two namespaces, and every locale has the same distribution.

# Total key count must be identical before and after
jq -s 'map(keys) | flatten | length' locales/en/*.json

# No key in two namespaces
npx tsx scripts/check-duplicate-keys.ts

# Every locale has the same namespaces with the same key sets
npx tsx scripts/check-locale-parity.ts

# Expected
#   2841 keys before, 2841 after
#   0 duplicates across 6 namespaces
#   4 locales, identical key sets in every namespace

Sequencing the split against translation work

A catalogue split is a structural change to files that translators are working in at the same time, and the two activities interact badly if nobody sequences them.

The interaction is simple to describe. While a surface is mid-migration, its keys exist in the new namespace and its translations arrive against whatever identity the translation system last saw. If a translator submits work for the old namespace after the move has been pushed but before the synchronisation runs, that work lands against keys that no longer exist in the source, and the sync reports it as an orphaned unit.

Three habits keep this from becoming a problem. The first is to migrate a surface when its translation queue is empty rather than in the middle of a translation cycle — usually right after a sync completes, which is the widest gap available. The second is to run the synchronisation immediately after the migration commit lands rather than waiting for the nightly job, so the window in which the two views disagree is minutes rather than hours. The third is to tell the localization team which surfaces are moving and when, in whatever channel they already use, because a surprised translator opens a support ticket and a warned one simply waits.

None of this is required for correctness — the fallback namespace already guarantees no reader sees a missing string. It is about not wasting translator time, which is the scarcest resource in the pipeline and the one most easily lost to a coordination gap.

There is one hard ordering rule. Never split a catalogue in the same release as a bulk copy edit. Both operations touch every locale file, both look like large diffs to the synchronisation job, and running them together makes it impossible to attribute any resulting loss to one cause or the other.

When to escalate

If the bundle does not get smaller after the split, the namespaces are being loaded eagerly. Splitting the files is only half the work; the loader has to request namespaces on demand for the route rather than preloading all of them, which in most frameworks is a configuration flag rather than a code change.

If merge conflicts do not decrease, the split followed the wrong axis. Contention concentrated in common after a split means the shared namespace absorbed strings that belong to specific surfaces — usually because “is this shared?” was answered by whether the English matched rather than by whether the meaning is genuinely context-free.

If translators start reporting missing context after the split, the namespace name has become part of the context they rely on. Adding the surface to each key’s note, rather than expecting the file name to carry it, restores what the single flat catalogue implicitly provided.

FAQ

How many namespaces is too many?

When a typical page needs more than three, the split has gone too far — every additional namespace is another request or another bundle entry, and the ownership benefit stops accruing once each namespace has fewer keys than a team can hold in mind. Somewhere between five and fifteen namespaces suits most products.

Should shared components have their own namespace?

Yes, if they are genuinely shared and have an owner. A design-system namespace containing the strings its components render is easy to reason about and easy to version. What does not work is a shared namespace used as a destination for anything a team could not classify.

Can the split be automated?

The mechanical part can: moving a listed set of keys across every locale file is a script. Deciding which keys belong together cannot be, because that decision is about ownership and meaning. Automating the move and hand-authoring the mapping is the right division.

What happens to translation memory during a split?

Nothing, provided the leaf keys and source text are unchanged. Memory matches on source content, so a key that moves namespaces still leverages its existing translation on the next pre-translation pass — which is the safety net described in translation memory and glossary management.

Does splitting help with untranslated-key gates?

It sharpens them. A per-namespace coverage threshold lets a new surface ship at eighty percent while a checkout flow is held at one hundred, which a single flat catalogue cannot express — the per-locale, per-namespace thresholds described in GitHub Actions i18n CI gates.

Part of String Catalog Governance.