The browser Field vs exports Conditions
Two mechanisms with the same name: the legacy top-level browser field and the browser condition inside exports. Which tools read each, and how to avoid them disagreeing.
A package ships both, they disagree, and two consumers get two different files:
{
"browser": "./dist/index.browser.js",
"exports": {
".": {
"browser": "./dist/index.browser.mjs",
"default": "./dist/index.mjs"
}
}
}
One is a legacy bundler convention; the other is a resolution condition. They share a name, are read by overlapping but different tool sets, and can point at different builds — which is how a bug report reads “works in Vite, broken in our webpack 4 build”.
Root Cause
The top-level "browser" field predates exports by years. It is a bundler convention, not part of Node’s resolution algorithm, and it has one capability the condition system lacks: it can map individual specifiers, including replacing a dependency with a different module or with false to stub it out entirely.
The "browser" key inside exports is a resolution condition, applied by the same first-match-wins walk that handles import and require. It cannot remap arbitrary specifiers; it selects which file an entry point resolves to when a bundler declares a browser target.
Modern bundlers read both, preferring exports when it covers the specifier. Older toolchains read only the legacy field. The result: with both present and disagreeing, the file a consumer receives depends on their build tooling rather than on your intent.
Minimal Reproduction
{
"name": "@scope/my-library",
"type": "module",
"main": "./dist/index.cjs",
"browser": "./dist/index.browser.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"browser": "./dist/index.browser.mjs",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
}
}
npx esbuild entry.ts --bundle --platform=browser --format=esm --outfile=/dev/null --metafile=meta.json
node -p "Object.keys(require('./meta.json').inputs).filter(k => k.includes('my-library')).join('\n')"
node_modules/@scope/my-library/dist/index.browser.mjs
A modern bundler takes the condition. An older one takes ./dist/index.browser.js — a different file, possibly a different module format, possibly stale.
Who Reads Which
Step-by-Step Fix
1. Pick one mechanism and make the other agree or disappear
For a package whose consumers all use modern tooling, the condition alone is enough:
{
"name": "@scope/my-library",
"type": "module",
- "main": "./dist/index.cjs",
- "browser": "./dist/index.browser.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"browser": "./dist/index.browser.mjs",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
}
}
If legacy support is genuinely required, keep the field and point it at the same file the condition serves:
{
"browser": "./dist/index.browser.mjs",
"exports": {
".": { "browser": "./dist/index.browser.mjs", "default": "./dist/index.mjs" }
}
}
2. Mind the condition ordering
browser and import can both be active for a bundler targeting the browser, and first match wins. Placing import above browser means such a bundler gets the generic ESM file and your browser build is never selected:
".": {
"types": "./dist/index.d.ts",
- "import": "./dist/index.mjs",
"browser": "./dist/index.browser.mjs",
+ "import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
Expected result: a browser-targeted build resolves index.browser.mjs; a Node build resolves index.mjs.
3. Use the field only for what conditions cannot express
The one capability worth keeping the legacy field for is per-specifier replacement — most usefully, stubbing a Node-only dependency out of a browser bundle:
{
"browser": {
"./dist/fs-cache.mjs": "./dist/memory-cache.mjs",
"node:fs": false
}
}
HAZARD PREVENTION
Symptom: A browser build fails with
Module "node:fs" has been externalized for browser compatibilityor an empty-module error, despite a browser condition being present.Root cause: The browser entry point transitively imports a Node-only module. Selecting a browser file at the entry point does not prevent internal imports reaching Node built-ins deeper in the graph.
Fix: Trace the import chain from the browser entry and replace the Node-specific module behind an internal conditional specifier, so the substitution happens where the dependency is used rather than only at the entry point.
Verification
# a browser-targeted build picks the browser file
npx esbuild probe.ts --bundle --platform=browser --format=esm --metafile=/tmp/b.json --outfile=/dev/null
node -p "Object.keys(require('/tmp/b.json').inputs).filter(k=>k.includes('my-library')).join(', ')"
# a Node-targeted build does not
npx esbuild probe.ts --bundle --platform=node --format=esm --metafile=/tmp/n.json --outfile=/dev/null
node -p "Object.keys(require('/tmp/n.json').inputs).filter(k=>k.includes('my-library')).join(', ')"
# no Node built-in reaches the browser graph
node -p "Object.keys(require('/tmp/b.json').inputs).filter(k=>k.startsWith('node:')).join(', ') || 'ok: no node builtins'"
Expected: the browser file in the first result, the standard file in the second, and ok: no node builtins.
Edge Cases / Gotchas
- The field’s object form is bundler-specific. Not every tool implements every mapping shape, and
falsestubbing in particular varies; test with the bundler your consumers actually use. - A stale field outlives the build that produced it. Renaming an output file updates the map and frequently leaves the legacy field pointing at a file that no longer exists — a failure only legacy consumers see.
browserinexportsdoes not imply a browser-safe graph. It selects one file; everything that file imports still needs to be browser-safe.- Bundlers may activate
browseralongsidemodule. Amodulecondition placed abovebrowsershadows it in some configurations, which is another ordering trap. - Node-targeted tests never touch the browser branch. Add an explicit browser-target build to CI or that file ships untested.
The summary worth carrying away is that these are not two spellings of one idea. One is a condition in a resolution algorithm; the other is a remapping table consulted by bundlers. Keeping both means maintaining two descriptions of the same intent, and the cost of them drifting apart is paid by whichever consumers happen to use the tooling that reads the stale one.
Frequently Asked Questions
Are the browser field and the browser condition the same thing?
No. The field is a bundler convention that can remap individual specifiers or stub them out; the condition is part of the exports resolution walk. Same name, different mechanisms.
Which one wins when both are present?
For tools that support exports, the map wins for specifiers it covers; the legacy field is consulted for anything outside it or by tools that ignore exports entirely.
Can the exports condition stub a module out entirely?
Not directly. The legacy field can map a specifier to false; the closest condition-based approach is pointing at a module that exports safe no-ops.
Do I still need the legacy field in 2026?
Only for tooling that does not read exports, or for per-specifier replacement. For most packages the condition alone is enough.
Does Node.js ever activate the browser condition?
No — Node activates node, import/require and default, so a file behind browser is never exercised by a Node-only test suite.
Related
- Browser vs Node.js Module Resolution — the resolution differences behind both mechanisms.
- Mastering the package.json Exports Field — condition ordering, which decides whether the browser branch is ever reached.
- Using the imports Field for Internal Aliases — substituting a Node-only module deeper in the graph.