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

Which tools read which mechanism Modern bundlers prefer the browser condition inside exports. Legacy bundlers and some framework wrappers read only the top-level browser field. Node.js reads neither, resolving through node, import or require conditions instead. Three audiences, and only one of them reads both modern bundlers Vite, webpack 5, Rollup prefer the exports condition fall back to the field for specifiers the map does not cover legacy tooling webpack 4, older wrappers reads only the field ignores exports entirely gets whatever the field happens to say Node.js reads neither node, import, require, default never the browser branch so a Node test suite never exercises that file

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
  }
}
Different capabilities, not just different syntax The exports browser condition selects which file an entry point resolves to. The legacy browser field can additionally remap arbitrary internal specifiers or replace a module with an empty stub. Keep the field only for what the condition cannot do exports "browser" condition selects an entry point file per subpath, per condition the modern default legacy "browser" field remaps arbitrary specifiers can stub a module with false keep only for these two

HAZARD PREVENTION

Symptom: A browser build fails with Module "node:fs" has been externalized for browser compatibility or 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 false stubbing 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.
  • browser in exports does not imply a browser-safe graph. It selects one file; everything that file imports still needs to be browser-safe.
  • Bundlers may activate browser alongside module. A module condition placed above browser shadows 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.



↑ Back to Browser vs Node.js Module Resolution