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.
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.
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.