Fixing 'Could not resolve node: Builtins' in a Browser Bundle
A browser-targeted bundle fails because your code (or a dependency) imports a Node built-in like node:fs or node:path that has no browser equivalent. This page shows the exact error, why the platform target triggers it, and how to fix it without shipping a broken artifact.
Exact symptoms and error messages
The build aborts with a resolution error naming a node: specifier:
✘ [ERROR] Could not resolve "node:fs"
node_modules/some-dep/index.js:2:19:
2 │ import fs from "node:fs";
╵ ~~~~~~~~~
The package "node:fs" wasn't found on the file system but is built into node.
You see it only when the bundler platform is browser (or neutral), because a Node platform build resolves built-ins automatically.
Root cause analysis
Node built-ins exist inside the runtime, not on disk, so a browser-target resolver has nothing to point the import at. The dependency that reaches for node:fs was written for a server, and pulling it into a browser graph forces the bundler to resolve a module that cannot exist client-side. This is the same platform boundary described in ESM and CJS Interoperability: the target environment, not your intent, decides what can resolve.
The resolver's platform setting also changes which package.json fields it honors. A browser-platform build reads the browser field and the browser export condition, so a well-behaved dependency can ship a browser-safe replacement that the bundler picks automatically. When you see the error, it usually means either the dependency has no browser build, or your bundler is running with a node/neutral platform that never looked for one. Understanding which condition wins is the same resolution ordering described in Understanding package.json Fields.
There is also a subtle transitive case: your code is browser-safe, but a dependency three levels down imports node:crypto for a feature you never call. Because bundlers resolve the whole reachable graph eagerly, that unused branch is still pulled in and still fails to resolve. Tree-shaking removes dead code after resolution, so it cannot rescue an import that fails to resolve in the first place — you have to externalize or alias it.
Resolution and configuration patch
Pick the fix that matches whether the built-in is genuinely needed in the browser:
- If it is a server-only dependency that leaked in, mark it
externaland load it only on the server path. - If you ship both a browser and a Node build, emit two outputs with different
platformvalues. - If you need a browser shim, alias the built-in to a polyfill explicitly.
// tsup.config.ts — separate browser and node targets
import { defineConfig } from 'tsup';
export default defineConfig([
{ entry: ['src/index.ts'], format: ['esm'], platform: 'browser',
external: ['node:fs', 'node:path'] },
{ entry: ['src/server.ts'], format: ['esm','cjs'],
platform: 'node' },
]);
A fourth option, when a browser-safe replacement genuinely exists, is to let the bundler honor the browser condition rather than hand-aliasing every built-in:
// tsup / esbuild — resolve the browser field and conditions
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
platform: 'browser',
esbuildOptions(options) {
options.mainFields = ['browser', 'module', 'main'];
options.conditions = ['browser', 'import'];
},
});
Reserve explicit polyfill aliases for built-ins that have a real browser shim (buffer, process, crypto-browserify); for fs, net, and child_process there is no meaningful shim and the code must stay server-side.
CLI validation and debug commands
Confirm which module drags in the built-in, then verify the fixed build resolves cleanly:
# Find who imports the node builtin
pnpm why some-dep
# Rebuild and confirm no unresolved node: specifiers remain
pnpm build 2>&1 | grep -i 'could not resolve' || echo OK
# Sanity-check the browser artifact has no require('fs')
grep -R "node:" dist/ || echo 'no node builtins in output'
Prevention and CI guardrails
- Split entry points by environment (
index.tsfor browser,server.tsfor Node) so leaks are structural, not accidental. - Add a CI grep step that fails when
node:specifiers appear in a browser artifact. - Keep server-only dependencies in a subpath the browser entry never imports.
- Set an explicit
platform/targetin the bundler config rather than relying on defaults.
Distinguishing a real need from an accidental leak
Before you polyfill anything, decide whether the browser bundle should touch the built-in at all. Most node: errors in a browser build are leaks — a server utility imported at the top of a shared module — not a genuine browser requirement. The test is simple: trace the import to the feature that uses it, and ask whether that feature runs in the browser.
If it does not, the fix is structural: move the server code behind a separate entry point (src/server.ts) so the browser entry never reaches it. If it genuinely does need, say, hashing in the browser, replace the Node API with a Web Platform equivalent (crypto.subtle instead of node:crypto) rather than shimming the Node module. Polyfilling should be the last resort, chosen only when a dependency you do not control forces it, because every shim adds bytes to the bundle your users download.
Splitting the package into environment entry points
The cleanest long-term structure is to make the environment split explicit in your exports map. Ship a browser entry and a node entry, and let the consumer's bundler pick the right one through the browser/node conditions, so a browser build never even resolves the server code.
{
"exports": {
".": {
"browser": "./dist/index.browser.mjs",
"node": "./dist/index.node.mjs",
"default": "./dist/index.node.mjs"
}
}
}
With two entries built from src/index.browser.ts and src/index.node.ts, the node: imports live only in the node entry, so the browser bundle is structurally incapable of pulling them in. This is more work than an alias, but it removes the failure mode entirely rather than patching each built-in as it surfaces.
Distinguishing a real need from an accidental leak
Before polyfilling anything, decide whether the browser bundle should touch the Node built-in at all, because most node: errors in a browser build are leaks — a server utility imported at the top of a shared module — not a genuine browser requirement. The test is simple: trace the import to the feature that uses it, and ask whether that feature runs in the browser. If it does not, the fix is structural rather than a shim.
When the built-in is only reached by server code, move that code behind a separate entry point (src/server.ts) so the browser entry never imports it, and split the package's exports so a browser condition resolves the browser entry. If a feature genuinely needs, say, hashing in the browser, replace the Node API with a Web Platform equivalent — crypto.subtle instead of node:crypto — rather than shimming the Node module. Polyfilling should be the last resort, chosen only when a dependency you do not control forces it, because every shim adds bytes to the bundle your users download. Framing the error as a question about which environment the code belongs in, rather than reaching immediately for a polyfill, usually reveals that the cleaner fix is to keep server code on the server.
Splitting the package into environment entry points
The durable structure for a package that runs in both environments is to make the split explicit in the exports map, shipping a browser entry and a node entry and letting the consumer's bundler pick the right one through the browser/node conditions. A browser build then never even resolves the server code, because the node: imports live only in the node entry.
{
"exports": {
".": {
"browser": "./dist/index.browser.mjs",
"node": "./dist/index.node.mjs",
"default": "./dist/index.node.mjs"
}
}
}
With two entries built from src/index.browser.ts and src/index.node.ts, the built-ins are structurally confined to the node build, so a browser bundle is incapable of pulling them in. This is more work than an alias, but it removes the failure mode entirely rather than patching each built-in as it surfaces, and it documents the package's dual-environment support in the manifest. For a library that genuinely serves both browser and Node consumers, environment-specific entry points are the clean answer — the consumer's platform selects the right code, and neither environment carries the other's dependencies.
Letting the bundler honor the browser field
When a dependency genuinely ships a browser-safe replacement, the cleanest fix is to let the bundler honor the browser field and export condition rather than hand-aliasing every built-in. A well-behaved package that uses node:crypto on the server may provide a browser build that uses crypto.subtle, exposed through its browser condition, and configuring the bundler to prefer that condition picks it up automatically.
// tsup / esbuild — resolve the browser field and conditions
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
platform: 'browser',
esbuildOptions(options) {
options.mainFields = ['browser', 'module', 'main'];
options.conditions = ['browser', 'import'];
},
});
Setting platform: 'browser' and listing browser first in mainFields/conditions means the bundler resolves each dependency's browser build where one exists, so a node: import in the server build is replaced by the browser build's alternative without you writing a single alias. This handles the common case where the dependency has already done the work of providing a browser-safe path; you just have to tell the bundler to look for it. Reserve explicit polyfill aliases for the packages that have no browser build and genuinely need a shim, so the bundler's own resolution handles the well-behaved majority and manual aliasing is the exception.
Frequently Asked Questions
Can I just polyfill Node built-ins for the browser?
Sometimes, for shim-able modules like buffer or crypto. But fs and net have no meaningful browser equivalent — the correct fix is to keep that code on a Node-only entry point.
Why does the Node build work but the browser build fail?
A Node-platform bundler resolves node: specifiers to the runtime automatically. A browser-platform bundler has no such runtime, so the same import becomes unresolvable.
Does tree-shaking remove an unused node built-in import?
No. Tree-shaking runs after module resolution, so an import that fails to resolve — like node:fs in a browser build — errors before dead-code elimination ever runs. You must externalize, alias, or restructure the import, not rely on tree-shaking to drop it.
Why does the bundler ignore the dependency's browser field?
Because the platform is set to node or neutral. Only a browser platform (or an explicit mainFields/conditions list that includes browser) makes the resolver prefer a dependency's browser build over its Node entry.
Should I polyfill Node built-ins for the browser?
Only for genuinely shim-able modules like buffer or process, and only when a dependency you don't control forces it. fs, net, and child_process have no meaningful browser equivalent — the correct fix is to keep that code on a Node-only entry point and split the package's exports by environment.
How do I stop server code from leaking into my browser bundle?
Split the package into a browser entry and a node entry, put the node: imports only in the node entry, and route them through browser/node conditions in the exports map. The browser build then structurally cannot import the server code.
Why does the bundler ignore my dependency's browser build?
Because the platform is set to node or neutral, so the resolver does not prefer the browser field or condition. Set platform: 'browser' (or list browser first in mainFields/conditions) and the bundler picks each dependency's browser build automatically.
Can I just add a fallback for the node built-in?
A resolver fallback (aliasing node:crypto to a browser shim) works only for built-ins that have a real browser equivalent, like buffer or crypto. For fs or net there is nothing to fall back to — the code must not run in the browser, so split it onto a Node-only entry point instead.
Why does the Node build work but the browser build fail?
A Node-platform bundler resolves node: specifiers to the runtime automatically. A browser-platform bundler has no such runtime, so the same import becomes unresolvable. The fix is to keep server code on a Node entry, not to force the browser build to resolve it.
Does tree-shaking remove an unused node built-in import?
No — tree-shaking runs after module resolution, so an import that fails to resolve (like node:fs in a browser build) errors before dead-code elimination runs. You must externalize, alias, or restructure the import; tree-shaking cannot drop something that never resolved.
Related
- ESM and CJS Interoperability — the platform and format boundaries behind this resolution error.
- Bundling and Build Tooling for Libraries — the overview for library build configuration.