Subpath imports cut both ways
TL;DR
- Importing
some-lib/narrow-thinginstead ofsome-lib(a subpath import) is usually a win. In one repo, importing one hook from the package root made Jest resolve 918 further modules and doubled a test suite, from 59s to 110s. The subpath resolved 19. - But if
some-libis shared between micro-frontends through Module Federation as a singleton (one copy for every app on the page), the exact same move takes it out of the singleton and duplicates it across every remote app. - What decides it is not who owns the package. It is whether the import string matches a share key in the app's config. That took me an outage's worth of reading to get right, and the part I got wrong for months was third-party packages: they duplicate exactly like your own.
- The remedy I'd reach for: instead of adding a second entry point, lazy-load the heavy part inside the library.
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:
- Jest resolves through the
requirecondition and gets the CJS build. That is where the 918 modules come from. - A bundler gets the ESM build, and that package declares
sideEffects: false: it tells the bundler that importing a module does nothing beyond its exports, so unused code can be dropped (tree-shaking). Your production chunk is probably tree-shaken fine. I have not measured the bundle delta.
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:
- A bare key, like
'@acme/design-system', matches that request. A request for'@acme/design-system/charts'does not match it. - A key ending in a slash, like
'react/', covers the subpaths under that name, including ones added later. That is a prefix share.
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:
- Duplicating
react/jsx-runtimecosts a few hundred bytes and nothing else. - Duplicating
react-dom/clientis a different conversation, since that is where the reconciler lives (the part of React that works out what to update).
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:
- The packaging choice (the
exportsmap, the entry points) is made in the library repo. - Whether a specifier is shared or duplicated at runtime is decided in the consuming app's MF config, which the library can't see and doesn't build against.
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
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.
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.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
- 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. - 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. - 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:
- If your build emits CSS to a constant filename (check
assetFileNamesin your config, a hand-rolled override likecss/${packageName}[extname]is common enough), a second entry collides on that filename. - And if exactly one file in your estate imports the library stylesheet, the second bundle ends up imported by nobody and the feature ships unstyled.
- Vite's default
assets/[name]-[hash][extname]doesn't have this problem.
What I learned
- Take the subpath for anything outside your share scope. For anything shared on a bare key, yours or not, a subpath escapes the singleton.
- Ask the running page before trusting the config. Listing the negotiated keys takes ten seconds and answers what no static check I know of will.
- Say what a number measures before you use it. A module count in the test runner is test cost, not bundle weight: a real cost, but not a bundle-size win until a bundle measurement says so.
- Before copying a pattern from a sibling, count how many files do it the other way.
- A change can look completely safe in the repo where it is made and slow down the estate that consumes it, with no error, no warning and no failing test. The November 2026 update describes the build guard that turned out to be the durable part.
- One packaging decision cannot serve both federated and standalone consumers. The only shape that does is the lazy import inside the library, and the November 2026 update says that is only partly true: through the library's CommonJS build, a static bundler puts the chunk back into the eager bundle (no cost yet, since every app bundles the ESM build), so it is not lazy everywhere. Its CSS does not split either, at least by default.
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:
- It parses
package.jsonwith Node rather than grep, because a key can map to a string or to a conditional object. - Stylesheet, font and type-only subpaths pass, since none of them carries a module instance.
- It sits at the head of the build, not in lint: in that library's CI, lint runs only on pull requests, while the build also runs on push and gates delivery.
- The error message points at the lazy chunk.