The node-polyfill-webpack-plugin package exists because webpack 5 stopped automatically bundling browser shims for Node.js core modules, and thousands of builds that silently relied on those shims broke on upgrade. If your build fails with "Module not found: Error: Can't resolve 'crypto'" or 'buffer' or 'stream', you have met this change. The blanket fix is to install the plugin and move on. The better fix is to understand what the error is telling you about your dependency tree, because "this library thinks it is running in Node" is information worth having.
What webpack 5 changed and why
Webpack 4 and earlier shipped automatic fallbacks: when bundled code required a Node core module like crypto, path, or buffer, webpack silently substituted a browser-compatible reimplementation (crypto-browserify, path-browserify, the buffer package). Convenient, but it had costs the webpack team ultimately rejected:
- Invisible bloat.
crypto-browserifyand its dependency chain add substantial weight that no one asked for, to support a require statement three levels deep in the tree. - False portability. Libraries "worked" in browsers by accident, discouraging authors from writing genuinely platform-neutral code.
- Misleading security surface. JavaScript reimplementations of Node's OpenSSL-backed crypto are not equivalent to the real thing, and pretending
cryptoexists in a browser obscures which primitives your code actually runs on.
Webpack 5 removed the automatic behavior. Now, resolving a Node core module in a browser build is a hard error unless you opt in per module via resolve.fallback.
The quick fix: node-polyfill-webpack-plugin
The plugin restores the webpack 4 behavior wholesale:
npm install --save-dev node-polyfill-webpack-plugin
// webpack.config.js
const NodePolyfillPlugin = require("node-polyfill-webpack-plugin");
module.exports = {
plugins: [new NodePolyfillPlugin()],
};
One plugin, every shim, build green. For a legacy app mid-migration this is a legitimate move: it unblocks the webpack 5 upgrade so you can capture its real wins (persistent caching, better tree shaking) and defer the polyfill audit. Newer versions of the plugin also let you restrict which shims it adds:
new NodePolyfillPlugin({
onlyAliases: ["buffer", "process"],
});
If you use the plugin at all, use it in that restricted form. The unrestricted default re-adds a dozen packages to your supply chain in one line.
The better fix: targeted fallbacks
The surgical approach configures resolve.fallback yourself, shimming only what your tree provably needs and explicitly disabling the rest:
module.exports = {
resolve: {
fallback: {
buffer: require.resolve("buffer/"),
stream: require.resolve("stream-browserify"),
crypto: false,
fs: false,
path: false,
},
},
};
Setting a module to false tells webpack to bundle an empty stub, which is correct when the requiring code path never executes in the browser (a common pattern in isomorphic libraries). The discovery step is the valuable part: for each "can't resolve" error, run your bundler's stats or npm ls <culprit> to find which dependency wants the module, then decide whether the right answer is a shim, a stub, or a different dependency.
A worked example: if the missing module is crypto, check what the dependency does with it. Hashing a cache key? The browser's built-in Web Crypto (crypto.subtle) or a small dedicated hash package beats bundling crypto-browserify. Generating keys or tokens client-side? Stop and review the design, because secret generation in browser JavaScript next to third-party code is usually the actual bug, and no webpack polyfill decision fixes it.
The supply chain math
Every polyfill is a real npm package with its own maintainers, release history, and transitive tree. The blanket plugin approach adds roughly a dozen of them — crypto-browserify, stream-browserify, buffer, process, util, assert, and friends — many of which are mature to the point of dormancy. Dormant infrastructure packages with enormous download counts are precisely the profile that account-takeover attacks target, as the ecosystem keeps relearning; see our write-up of the eslint-config-prettier maintainer compromise for how that plays out.
Practical rules:
- Prefer zero shims, then few shims, then the restricted plugin. Never the unrestricted default in a mature codebase.
- Scan what the shims drag in. Polyfills arrive as dev-invisible transitive weight, but they ship to production in your bundle. Your composition analysis should treat them as production dependencies, because browsers execute them; an SCA tool like Safeguard will attribute advisories in
crypto-browserify's tree back to the plugin that introduced it. - Re-audit on dependency upgrades. Libraries increasingly ship browser-native builds. A shim your tree needed last year may be dead weight today. Bundle analyzer output makes removed-shim wins visible.
When the error means "wrong library"
Sometimes "Can't resolve 'fs'" is not a polyfill problem but a packaging problem: you imported a server-side entry point of a dual-runtime library, or you picked a Node-only library for browser work. Modern packages declare browser-specific builds via the browser and exports fields in package.json; check whether the dependency offers one before shimming around its absence. Swapping to a browser-first alternative usually deletes both the error and the added attack surface, which is the rare fix that improves performance and security simultaneously. For a broader look at evaluating frontend dependencies before they land, our npm security best practices guide covers the vetting workflow.
Recommended migration sequence
- Upgrade to webpack 5 with
NodePolyfillPluginunrestricted to get green. - Enable bundle analysis and list every shim actually included.
- For each shim, identify the requiring dependency and classify: needs shim, needs stub (
false), or needs replacement. - Move to explicit
resolve.fallbackentries or the plugin's restricted mode; delete the blanket plugin usage. - Add a CI check on bundle composition so new shims arrive as reviewed decisions, not surprises.
Teams that stop at step 1 carry the full webpack 4 polyfill surface forever. The remaining steps are a day or two of work and typically shrink the bundle as a side effect.
FAQ
Why does webpack 5 say it cannot resolve crypto or buffer?
Webpack 5 removed the automatic Node core module polyfills that webpack 4 injected. Any dependency requiring crypto, buffer, stream, or similar in a browser build now produces a resolution error until you configure resolve.fallback or add a polyfill plugin.
Should I use node-polyfill-webpack-plugin or resolve.fallback?
Use the plugin (ideally restricted to specific aliases) to unblock a migration quickly; use explicit resolve.fallback entries as the end state. Targeted fallbacks keep your bundle and your dependency surface limited to shims you can name and justify.
Is it safe to set a fallback to false?
Yes, when the code path requiring that module never runs in the browser. Webpack substitutes an empty module, and isomorphic libraries commonly guard Node-only branches so the stub is never touched. Test the affected flows, since a wrongly stubbed module fails at runtime rather than build time.
Do browser polyfills of Node crypto provide the same security?
No. Packages like crypto-browserify reimplement Node's OpenSSL-backed API in JavaScript. For hashing and encryption in browsers, prefer the platform's built-in Web Crypto API, and treat any client-side secret generation as a design smell worth reviewing.