Two i18n instances in a workspace

Strings resolve correctly in the application’s own components and render as raw keys inside anything imported from the shared component library. Switching language updates half the page. The catalogue is fine, the keys exist, and the same key works ten lines earlier in a different component.

There are two resolver instances in the process. One has messages; the other does not. Which one a component sees depends on which module created it.

Two resolver instances in one process The application creates a resolver directly at startup, producing one instance. A shared component package asks the runtime package for a resolver, which creates a second one. Messages loaded into the first instance are invisible to the second, so components from the shared package resolve nothing. How two instances come to exist app/main.ts ui package runtime pkg vue-i18n createI18n() — instance A getI18n() createI18n() — instance B B has no messages installed
Both calls succeed. The failure is that neither knows about the other.

Root cause: nothing prevents a second instance

Every i18n library exposes a factory — createI18n, createInstance, new IntlProvider — and nothing about calling it twice is an error. Each call produces an independent object with its own catalogue, its own active locale and its own subscribers.

In a single application that is hard to do by accident. In a workspace it is close to the default, because two reasonable decisions collide: the application initialises i18n at startup because that is what the framework documentation shows, and the shared package obtains a resolver from a runtime package because it cannot depend on the application. Neither knows the other exists.

The result is asymmetric and therefore confusing. Messages are usually loaded by whichever instance the application created, so application components work. Shared components hold the other instance, find an empty catalogue and return the key. A locale switch dispatched on one instance updates its subscribers and leaves the other showing the previous language.

Three duplications with nearly identical symptoms Two resolver instances make keys resolve in some component trees and not others. Two physical copies of the runtime package make a locale switch update only part of the page. Two versions of the message package leave a corrected string wrong in whichever consumer holds the old version. What each duplication looks like Symptom Tell Two instances keys resolve in some components only the same key works in one tree Two package copies locale switch updates half the page two entries in the lockfile Two catalogue versions a fixed string is still wrong somewhere two versions installed
The symptoms overlap; the lockfile is what tells them apart in seconds.

Two more duplications with the same symptoms

Before fixing the code, it is worth distinguishing three problems that present almost identically.

Two instances is the case above: one copy of the library, called twice.

Two physical copies of the package happens when two consumers depend on incompatible version ranges and the package manager installs both. Now even a correctly memoised accessor produces two instances, because the memo lives in module scope and there are two modules. The tell is the lockfile: the same package name appearing at two versions.

Two catalogue versions is subtler still. One instance, one runtime, but two consumers pinned to different versions of the messages package — so a corrected string is fixed in one application and stale in another. Nothing resolves incorrectly; the content is simply older in one place.

The diagnosis for all three starts in the lockfile, which takes seconds and eliminates two of the three possibilities immediately.

The fix: one accessor, one copy, one version

// packages/i18n-runtime/src/index.ts
import { createI18n } from 'vue-i18n';

let instance: ReturnType<typeof createI18n> | undefined;

export function getI18n() {
  // Memoised at module scope: one instance per module instance.
  instance ??= createI18n({ legacy: false, fallbackLocale: 'en', messages: {} });
  return instance;
}

The memo only holds if there is exactly one copy of this module in the process, which is what the dependency declaration has to guarantee:

// packages/ui/package.json — the shared component library
{
  "peerDependencies": { "@acme/i18n-runtime": "^3.0.0" },
  "devDependencies":  { "@acme/i18n-runtime": "^3.0.0" }
}

Declaring the runtime as a peer dependency says: I use this, but the consumer supplies it, and there must be one. A regular dependency invites the package manager to install a private copy the moment version ranges diverge, which reintroduces the problem after an unrelated upgrade.

Five steps to a single resolver instance Every direct resolver construction outside the runtime package is removed, the runtime exports one memoised accessor, the runtime is declared a peer dependency so consumers cannot install their own copy, the lockfile is deduplicated to one hoisted version, and a test asserts that two importers receive the same object. Collapsing to one instance 1 Delete every direct createI18n call outside the runtime package 2 Export a single accessor memoised at module scope 3 Mark the runtime as a peer dependency consumers must share one copy 4 Dedupe the lockfile one version, hoisted 5 Assert instance identity in a test two importers, one object
The peer dependency is what makes it stay fixed — without it, the next install can undo the other four.

Why frameworks make this easy to get wrong

The pattern is not a mistake anyone makes out of carelessness; it is what the documentation teaches, applied in a context the documentation does not cover.

Every framework’s getting-started guide creates the i18n instance in the application entry point, because in a single application that is exactly right — it is explicit, it is easy to find, and there is nowhere else it could live. Copying that into a workspace puts instance creation in a place shared packages cannot reach, so they create their own.

The inversion that fixes it is to treat the resolver as infrastructure owned by a package rather than as application setup. The application then configures the resolver — supplying the locale list, the fallback chain, the missing-key handler — but does not create it. Configuration is data and can be passed in; creation is identity and must happen once.

That inversion has a second benefit worth naming: tests get the same instance the application gets, which removes an entire category of test-only i18n setup that drifts from production configuration.

Reading the lockfile

Because two of the three duplications are visible in the lockfile, learning to read it for this specific question is worth a few minutes and saves hours.

What you are looking for is the same package name appearing more than once with different resolved versions, and — separately — the same version appearing at more than one path in the tree. The first means incompatible ranges forced two installs. The second means hoisting did not apply, usually because a nested dependency declared a conflicting range of its own.

Package managers differ in how they present this. pnpm’s why command shows every path that leads to a package, which answers the question directly. npm’s ls with a package name does the same in a different shape. In both cases the useful output is the number of distinct entries: one is correct, more than one is the bug.

The fix depends on which case it is. Incompatible ranges are resolved by widening one of them, which usually means someone pinned an exact version defensively and should not have. Failed hoisting is resolved by an override or resolution entry that forces a single version across the tree — a blunt instrument, but the right one when a transitive dependency is the obstacle.

One caveat: forcing a single version of a package whose major versions genuinely differ is how you turn a duplication bug into a runtime crash. Check that the ranges are compatible in substance before flattening them, rather than only that the resulting tree looks tidy.

Verification

// Two independent importers must receive the same object.
import { getI18n as fromApp } from '@acme/i18n-runtime';
import { getI18n as fromUi } from '@acme/ui/internal/i18n';

test('exactly one i18n instance in the process', () => {
  expect(fromApp()).toBe(fromUi());
});
# Exactly one copy of the runtime, at one version
pnpm why @acme/i18n-runtime
#   expected: a single entry, deduped

# Nothing outside the runtime package constructs a resolver
grep -rn "createI18n(" apps/ packages/ --include='*.ts' \
  | grep -v 'packages/i18n-runtime' && exit 1 || echo 'single construction site'

The toBe assertion is deliberate — reference equality is the property in question, and a deep-equality check would pass on two independently created instances, which is exactly the bug.

When to escalate

If instance identity holds and shared components still show raw keys, the messages were loaded into a namespace the shared package does not request. That is a namespace scoping problem rather than an instance problem, and it is covered in duplicate keys across namespaces and in the package layout in monorepo i18n package architecture.

If the symptom appears only in a server-rendered context, remember that a module-scope memo lives for the process, not the request. Sharing one instance across concurrent requests with different locales is a different bug with a similar look — the correct shape there is one instance per request, obtained from request-scoped storage rather than from module scope.

If two copies keep reappearing after a dedupe, some consumer is pinning an exact version. Version ranges that cannot overlap force the package manager to install both, and no amount of deduping resolves an impossible constraint.

FAQ

Is a singleton the right pattern here?

For the client, yes — a browser tab has one active locale, so one instance is the honest model. On the server, no: concurrent requests can have different locales, so the instance belongs to the request. A runtime package that serves both should expose an accessor that reads from request-scoped storage when it exists and falls back to the module-scope memo when it does not.

Why does it work in development and break in a production build?

Bundlers deduplicate differently from package managers, and a development server that resolves modules from source may collapse two copies that a production build keeps separate — or the reverse. The lockfile is the reliable signal; the development behaviour is not.

Should the runtime package be a peer dependency of applications too?

No. Applications are the top of the graph and supply the dependency, so a regular dependency is correct there. Peer dependencies belong on the shared libraries that consume it, which is what expresses “the application must provide exactly one of these”.

How do we stop a new package from reintroducing this?

The grep in the verification section is a lint rule waiting to happen. Forbidding the factory call outside the runtime package is a mechanical check that takes minutes to write and prevents an afternoon of debugging every time someone adds a package.

Part of Monorepo i18n Package Architecture.