Clément Farkas DauduFR

Subpath imports cut both ways

TL;DR

Barrels, subpaths and the exports map

A package's root entry is often a barrel: one file that re-exports everything the package offers. import { x } from 'some-lib' goes through that file.

A subpath import asks for one module instead: import { x } from 'some-lib/x'.

A package can list the paths it offers in the exports map of its package.json, each pointing at a file, or at one file per condition (see below). Every key there is an entry point. "." is what 'some-lib' gets, and a key like "./charts" is a subpath export, what 'some-lib/charts' gets.

The good case: one hook, through the barrel, made Jest resolve 918 modules

A component needed one hook out of a big accessibility library. The obvious import goes through the barrel:

import { useFocusRing } from 'ui-primitives'

The narrow import goes straight to the hook:

import { useFocusRing } from 'ui-primitives/useFocusRing'

It works because the project uses moduleResolution: "bundler" and the lib ships an exports map.

Import Modules resolved Effect on the tests
'ui-primitives' (barrel) 918 further CJS modules Jest suite from 59s to 110s, two chart suites failing
'ui-primitives/useFocusRing' (subpath) 19 modules

Both chart suites failed because a waitFor on a deferred chunk (code loaded later, on demand) only had the 1s default, and the extra modules pushed it over.

That number is test cost, not bundle weight

Be precise about what that number is, because I wasn't at first. That is test-runner resolution cost, not bundle weight.

The library ships two builds of the same code: CommonJS (CJS, loaded with require()) and ES modules (ESM, loaded with import). Its exports map hands each tool the build it asks for:

Which is not a reason to shrug. Doubling a test suite's runtime is a real cost that nobody attributes to an import statement, and it is the kind of thing that quietly makes a repo unpleasant to work in. Just don't let anyone sell you the barrel-vs-subpath change as a bundle-size win without a bundle measurement.

The trap is copying your neighbours

The trap here isn't technical, it's social: four existing components in the same repo imported from the bare barrel. Copying your neighbours is normally the right instinct, and here it costs you 900 module resolutions. Before you copy a pattern from a sibling, count how many files do it the other way.

Module Federation in two minutes

The rest of this article is about micro-frontends: one product assembled from several apps that are built and deployed separately. I call the whole set an estate. Module Federation (MF) puts them together at runtime: a host app, also called the shell, loads code from remote apps.

By default, each app's build bundles every package it imports, so two remotes using the same library each carry a copy. The shared section of the MF config changes that for the packages it lists. Each build can provide its own copy, and at runtime the apps agree on which copy is used, checked against the version range in requiredVersion. That runtime registry is the share scope. singleton: true goes further and asks for one instance that every remote uses.

What counts as shared is decided by the share key, the name of each entry under shared. MF compares it with the module request, the string after from in an import statement (also called the specifier). Two kinds of key matter here:

The bad case: a new entry point duplicates the design system

The package is a design system, published internally, consumed by a micro-frontend estate. Module Federation shares it as a singleton so every remote uses one instance.

Someone wants to ship a heavy feature (charts, a PDF viewer, anything with real weight) without making every consumer pay for it. Intuitive move, a second entry point:

{
  "exports": {
    ".": "./dist/es/index.js",
    "./charts": "./dist/es/charts.js"
  }
}

Isolation, right?

No. In a federated estate, a new subpath export is not a weight isolation mechanism, it's a duplication mechanism.

Why the subpath escapes the singleton

MF shared keys are matched against the module request. The host declares:

shared: {
  '@acme/design-system': { singleton: true, requiredVersion: '^19.0.0' },
}

That is a bare key. A request for @acme/design-system/charts doesn't match it, so it falls outside share scope negotiation entirely. Every remote importing it bundles its own copy of that entry, plus its own unshared copy of whatever internals it drags along (the icon component, the theme context, the stylesheet).

flowchart LR
  R1["Request: '@acme/design-system'"] -->|"matches the bare key"| S["Share scope: one instance for every remote"]
  R2["Request: '@acme/design-system/charts'"] -->|"matches no key"| D["Each remote bundles its own copy"]

What a prefix share does, and the folklore I used to repeat

Look at a working MF config and React is usually declared twice:

shared: {
  'react':      { singleton: true, eager: true, requiredVersion: '^19.0.0' },
  'react/':     { singleton: true, eager: true, requiredVersion: '^19.0.0' },
  'react-dom':  { singleton: true, eager: true, requiredVersion: '^19.0.0' },
  'react-dom/': { singleton: true, eager: true, requiredVersion: '^19.0.0' },
}

I used to explain the trailing-slash entry as "it makes react/jsx-runtime resolve to the same instance as react". That is wrong, and it's worth correcting because it's a common piece of folklore.

react/jsx-runtime is a separate module. It does not import react at all. It builds elements from Symbol.for('react.transitional.element'), which uses the cross-realm symbol registry: a global registry, so every copy gets the same symbol. Duplicate it and the copies still produce elements the shared React accepts. There is no instance to preserve.

What a prefix share does is simpler: it makes the subpath module itself a share-scope entry, so it gets deduplicated and version-negotiated across the estate instead of being bundled once per remote. The stakes are therefore proportional to what sits behind the subpath:

Same mechanism for your own library, and this is the point: your package almost certainly has no '@acme/design-system/' sibling. Nobody thinks to add one, because the package only ever had one entry point when the config was written.

Why nobody notices: the decision is split across two repositories

The two halves of the decision live in different repositories:

From inside the library repo, adding an export looks completely safe. Local dedup checks inspect the library's own built bundle; they have zero visibility into share-scope negotiation happening in someone else's build. No error, no warning, no failing test. The only symptom is that the estate gets slower, and nobody attributes that to a one-line change in an exports map.

How to check whether you have this bug right now

  1. At runtime. Open a federated page in the browser and read the share scope from the console:

    Object.keys(__webpack_share_scopes__.default)
    

    That lists every key that was actually negotiated. If the module you assumed was shared isn't in there, it isn't shared. Takes ten seconds and answers the question directly, which is more than any static check I know of will do for you.

  2. In the config. Grep your MF config for bare keys with no 'name/' sibling. Every one of them is a package whose subpaths will duplicate.

  3. For third-party packages too, which is the part I got wrong for months. A typical estate shares a router, a data-fetching library, an i18n runtime and a state store on bare keys with no siblings. A subpath import of any of those duplicates exactly the same way a subpath of your own library does. Ownership has nothing to do with it.

One packaging decision cannot serve both kinds of consumer

I originally wrote that a subpath export "fails both topologies at once". That's too strong, and the reviewer who pushed back on it was right.

A published library is usually consumed in two ways, which I call topologies:

Topology How the app gets the library What a subpath export with sideEffects: false does there
Federated Through Module Federation Breaks the singleton
Standalone Installed directly The textbook mechanism for isolating optional weight. It works

So the real argument isn't "it fails both". It's that one packaging decision cannot serve both topologies, and the only shape that does is the lazy import inside the library.

That distinction matters for another reason. It's tempting to reason "the design system is shared, so its weight is paid once by the host". Only true for the federated half. Standalone apps pay every added kB in full, per app, whether they use the feature or not.

The remedies, from best to worst

  1. Lazy-load inside the library. A dynamic import() behind the heavy component (it loads that code as a separate chunk, only when it is needed) keeps a single shared instance and still keeps the weight out of the initial chunk (the code loaded up front). Transparent for consumers, no coordination with another repo. This is the one I'd reach for.
  2. Declare the exact subpath as its own share key. '@acme/design-system/charts' alongside the bare key. Narrower than a prefix share, which silently share-scopes every future subpath you add.
  3. Add the prefix share. Works, and it lands in the consuming repo, in a config that is often guarded by invariants (rules it must always satisfy) precisely because per-entry sharing decisions are how shells go down. One-way door, hard to walk back: price it as such.

Two keys may mean two instances

One caveat on the last two that I have not verified and won't pretend to: the bare key and the subpath key become two independent share-scope entries. If your subpath entry reaches shared internals (a theme context, say) through relative imports, whether both entries end up with one instance or two depends on which build provides each entry.

Host provides the root, a remote provides /charts, and you could plausibly get two theme contexts and silent default values. Test it before you rely on it.

A quieter trap: a second entry and a constant CSS filename

There is a second-order trap worth knowing, since it is even quieter:

What I learned

Update, November 2026

The lazy chunk I recommended above now has a number, and one claim I made for it needs narrowing.

The lazy chunk, measured. On a charting dependency, a lazy chunk inside the library (a dynamic import()) needed no change to the consumer's import statement. On one build, it changed the weight that dependency added to the library entry like this:

Library entry Weight the charting dependency adds (gzip)
Before the lazy chunk +104,849 bytes
With the lazy chunk +1,348 bytes, 98.7% deferred

CSS does not split, though, at least by default: Vite's library mode (its build for publishing a library) leaves cssCodeSplit off, so Vite hoists every stylesheet of the build, the lazy part's included, into the single CSS bundle. The residue was about 174 gzip bytes, and splitting it would mean a second published stylesheet and a second import in every app.

One claim narrowed. I also wrote "the only shape that does is the lazy import inside the library". That is only partly true. The library ships a CommonJS build too, and there the dynamic import degrades to Promise.resolve().then(() => require(...)). The split survives on disk, but a static bundler resolves that require as an ordinary dependency and puts the chunk straight back into the consumer's eager bundle, the code loaded up front. Every app bundles the ESM build today, so it costs nothing yet. Just don't tell app teams it's lazy everywhere.

The guard. The durable part turned out to be a guard, not the remedy. The rule lived only in a decision note, so nothing stopped someone adding ./charts to exports next week, with the library's CI green and the duplication showing up in another repo's runtime. The guard I settled on fails the library build when exports gains a second JavaScript entry: