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.
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.
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.
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.
Related
- Monorepo i18n Package Architecture — the package boundaries that make one instance possible.
- Nuxt i18n lazy messages missing after a locale switch — the same duplication in a single-application setting.
- Vue i18n Composition API Guide — what the instance actually owns.
- String Catalog Governance — the namespace scoping that the second failure mode involves.
Part of Monorepo i18n Package Architecture.