Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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:

Exact symptoms and error messages The build aborts with a resolution error naming a node: specifier: Exact symptoms and error messages The build aborts with a resolution error naming a node: specifier:
Exact symptoms and error messages — the core idea of this section at a glance.
✘ [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.

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. 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.
Root cause analysis — the core idea of this section at a glance.

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:

Resolution and configuration patch Pick the fix that matches whether the built-in is genuinely needed in the browser: Resolution and configuration patch Pick the fix that matches whether the built-in is genuinely needed in the browser:
Resolution and configuration patch — the core idea of this section at a glance.
  1. If it is a server-only dependency that leaked in, mark it external and load it only on the server path.
  2. If you ship both a browser and a Node build, emit two outputs with different platform values.
  3. 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:

CLI validation and debug commands Confirm which module drags in the built-in, then verify the fixed build resolves cleanly: CLI validation and debug commands Confirm which module drags in the built-in, then verify the fixed build resolves cleanly:
CLI validation and debug commands — the core idea of this section at a glance.
# 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.ts for browser, server.ts for 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/target in the bundler config rather than relying on defaults.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.

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.

Leak or requirement Whether a node built-in is a genuine browser need or a server leak. Does the feature run in the browser? no move to server entry yes, shimmable Web API / polyfill yes, unshimmable keep server-only
Trace the import before polyfilling — most node: errors are leaks, not requirements.

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.

Env entry points One package, a browser entry and a node entry via exports conditions. src/index.browser.ts Web APIs only browser condition picked by bundler node entry isolated node: stays server-side
Separate entries make a browser bundle structurally unable to import node built-ins.
{
  "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

Bundling and Build Tooling for Libraries