Quick answer
Set resolve.fallback.fs = false in your webpack config. If that's not enough, find the dependency actually importing fs — it almost always shouldn't be in a browser bundle in the first place.
Why this error shows up now
Webpack 4 quietly shipped polyfills for Node's core modules. If your code or a dependency did require('fs'), webpack 4 handed back a browser shim and everything kept moving. Webpack 5 removed all of that. No more automatic polyfills for fs, path, crypto, os, stream — none of it. The team's reasoning was sound: most of these shims were bloating bundles for code paths that never ran in the browser anyway. The side effect is that anything that used to silently resolve now throws Module not found: Error: Can't resolve 'fs' the moment you upgrade.
The typical trigger I see in the wild: someone bumps webpack from 4 to 5 in a Next.js or Vue CLI project, or updates a transitive dep like dotenv, winston, or an old version of csv-parse, and suddenly the build dies with a wall of "can't resolve" errors. Nine times out of ten the offending import is buried three levels deep in node_modules and nobody on the team has ever seen that file.
The fix that works for 90% of cases
-
Open
webpack.config.js(orwebpack.renderer.config.js, or wherever your browser target lives). -
Find the
resolveblock. If you don't have one, add it at the top level of the config object. -
Add a
fallbackentry that tells webpack to resolvefsto an empty module:module.exports = { // ...your existing config resolve: { fallback: { fs: false, path: require.resolve('path-browserify'), crypto: require.resolve('crypto-browserify'), stream: require.resolve('stream-browserify'), buffer: require.resolve('buffer/') } } }; -
Rebuild. If
fswas the only missing module, the error goes away immediately. You'll get more errors forpath,crypto, etc. if the same dependency touches those — add them one at a time as they appear. -
If your config uses ESM (
webpack.config.mjs), swaprequire.resolveforimport.meta.resolveor just pre-import the shim packages.
What fs: false actually does
It tells webpack to substitute an empty module for that import. Any code that calls fs.readFileSync() at runtime will throw TypeError: fs.readFileSync is not a function. That's fine if the code path never runs in the browser. It is not fine if something actually tries to read a file — and if that's your case, stubbing isn't a fix, it's a delay. You'll find out in production.
If stubbing fs breaks things at runtime
You've got three real options. Pick based on what the offending import actually does.
Option 1: Mark the package as external
If the module is only used in a Node context (Electron main process, SSR, a CLI script bundled by the same config), don't bundle it:
module.exports = {
externals: {
fs: 'commonjs fs'
}
};
This is right for Electron renderers where you're supposed to use contextBridge or preload scripts. It's wrong for a pure browser target — you'll just get a runtime error instead of a build error.
Option 2: Find and replace the dependency
Run this to see who's pulling fs in:
npx webpack --stats-error-details
The output names the exact package and file. Common offenders in 2024:
- Old
dotenv@8— upgrade todotenv@16 winston— useloglevelorpinoin the browser insteadcsv-parse@4— usepapaparse- Anything wrapping
fs.readFilefor config loading — that pattern doesn't belong in browser code
Swapping the dep is the correct fix. Stubbing is the pragmatic fix. Don't confuse the two.
Option 3: Use node-polyfill-webpack-plugin (last resort)
Everyone's first instinct is to install node-polyfill-webpack-plugin and be done. It works, but it drags in browserify shims for a dozen core modules and can add 200KB+ to your bundle. If you're shipping to the browser, that's a bad trade. Use it only when you've genuinely got browser code that needs a polyfilled core module and you can't find a lighter alternative.
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
module.exports = {
plugins: [new NodePolyfillPlugin()]
};
Prevention
Two things stop this from coming back. First, add a browser field to your package.json for any package you publish that has Node-only entry points — it tells bundlers which file to use in browser targets. Second, run npx webpack --stats-error-details in CI on every build. The first time a dependency silently adds an fs import, you'll catch it in a PR rather than two weeks later when someone bumps a version on a Friday.
If you're on Next.js, don't touch the webpack config for this. Add
config.resolve.fallback = { fs: false }insidenext.config.js'swebpackcallback, or better, mark the package as server-only with theserver-onlynpm package. Next's App Router will handle the rest.
Quick reference table
| Symptom | Likely cause | Fix |
|---|---|---|
| Build fails immediately after webpack 5 upgrade | Transitive dep imports fs | resolve.fallback.fs = false |
| Build passes, runtime throws | Stubbed fs is actually called | Replace the dep or use externals |
| Electron renderer crash | Direct fs use in renderer | Move to preload, use contextBridge |
| Bundle bloat after fix | node-polyfill-webpack-plugin | Remove it, stub selectively |