webpack 5, and the polyfills that stopped being automatic

The webpack 5 upgrade went in on a Tuesday and the build failed with six errors, all of the same shape: a package importing a Node core module. In webpack 4 that had worked, silently, by bundling a browser reimplementation of crypto — and the bundle had been 340 kilobytes larger than anybody realised.

The symptom

$ npx webpack --mode production

ERROR in ./node_modules/jsonwebtoken/sign.js 4:15-32
Module not found: Error: Can't resolve 'crypto'

BREAKING CHANGE: webpack < 5 used to include polyfills for
node.js core modules by default. This is no longer the case.
Verify if you need this module and configure a polyfill for it.

If you want to include a polyfill, you need to:
  - add a fallback 'resolve.fallback: { "crypto":
    require.resolve("crypto-browserify") }'
  - install 'crypto-browserify'
If you don't want to include a polyfill, you can use an empty
module like this: resolve.fallback: { "crypto": false }

... 5 more

The error message is unusually good and it tells you both options without telling you which one is right. Choosing correctly requires knowing why the package wanted crypto, which the error cannot know.

Why it happens

webpack 4 shipped browser implementations of Node core modules and applied them automatically, which made a lot of npm packages work in a browser without anybody thinking about it. The cost was invisible: a package importing crypto for one function pulled in an entire implementation, and nothing in the build output said so.

webpack 5 removed the automatic behaviour and kept the mechanism. The failure is the point — it surfaces a decision that had been made silently, six times, over four years.

The fix

Deciding per module, which is six separate questions

module.exports = {
  resolve: {
    fallback: {
      // needed: jsonwebtoken verifies signatures in the browser
      crypto: require.resolve('crypto-browserify'),
      stream: require.resolve('stream-browserify'),

      // not needed: a dead branch behind a typeof window check
      fs: false,
      path: false,
      os: false,
      util: false,
    },
  },
}

Four of the six were dead branches — code guarded by an environment check that never runs in a browser, which webpack still resolves because it does not evaluate the guard. Setting those to false is correct and removes them from the bundle entirely.

The two real ones were jsonwebtoken, which needed both crypto and stream because it genuinely verifies signatures client-side. That prompted the better question — whether verifying a token in the browser is worth 280 kilobytes when the server verifies it anyway — and the answer was no, so the package was removed and the fallbacks with it.

$ npx webpack-bundle-analyzer dist/stats.json

before  main.js  1,412 KB
  crypto-browserify + deps    282 KB
  stream-browserify + deps     61 KB
  buffer                       48 KB

after   main.js  1,021 KB

# 391 KB of Node reimplementation that nothing needed,
# shipped to every visitor since 2017.

The filesystem cache, which is the reason to upgrade

cache: {
  type: 'filesystem',
  buildDependencies: { config: [__filename] },   // invalidate
  cacheDirectory: path.resolve(__dirname, '.webpack-cache'),
}
$ rm -rf .webpack-cache && time npx webpack --mode development
real	0m38.402s
$ time npx webpack --mode development
real	0m4.118s

# in CI, with the directory restored from actions/cache:
#   cold 41s, warm 9s

This is the change that justifies the upgrade on its own. webpack 4 had an in-memory cache that died with the process, so every fresh build and every CI run paid the full cost; the filesystem cache survives and is safe to restore across runs.

The buildDependencies entry is what stops the cache being wrong: without it, changing the webpack configuration does not invalidate anything and the build silently uses stale output. That is a debugging session nobody enjoys, and the three lines prevent it.

Deterministic ids, and why the vendor hash stopped changing

optimization: {
  moduleIds: 'deterministic',    // the default in 5 production
  chunkIds: 'deterministic',
  runtimeChunk: 'single',
  splitChunks: {
    cacheGroups: {
      vendor: {
        test: /[\/]node_modules[\/]/,
        name: 'vendor',
        chunks: 'all',
      },
    },
  },
}

webpack 4 numbered modules by resolution order, so adding one import anywhere renumbered everything after it and changed the vendor chunk hash — which invalidated a cached file for every returning visitor on every deploy. Deterministic ids hash the module path instead, so the vendor chunk hash only changes when a dependency changes.

The runtimeChunk: single is the other half: the runtime holds the module map, so it changes on every build, and separating it into its own small file keeps that churn out of the vendor chunk. Together they are the difference between a returning visitor downloading 900 kilobytes after every deploy and downloading nothing.

The parts that are just work

file-loader, url-loader, raw-loader   → asset modules
  { test: /.png$/, type: 'asset/resource' }

node: { fs: 'empty' }                → resolve.fallback

optimization.splitChunks.name: false is now the default

named exports from JSON removed — import the default

automatic Node.js polyfills gone (the six errors above)

requires node 10.13+. and webpack-cli 4, which is a
separate major and has its own argument changes.

Asset modules are a genuine simplification — three loaders and their configuration replaced by a type field — and the migration is mechanical. The webpack-cli version bump is the one that surprises people, because it is a separate package with its own breaking changes and the upgrade guide covers it only in passing.

Verifying it worked

$ npx webpack --mode production
asset vendor.8f21ac3d.js  1,021 KB
asset main.d4e2b19f.js      184 KB
asset runtime.2a0c1f88.js     3 KB

# build twice with no source change: hashes identical
$ npx webpack --mode production && ls dist/*.js > /tmp/a
$ rm -rf dist .webpack-cache && npx webpack --mode production
$ ls dist/*.js | diff /tmp/a -

# add one import to a component, rebuild:
#   main hash changed, vendor hash UNCHANGED  ← the point

$ npx playwright test
  38 passed

The reproducible-hash check is worth automating because it catches a whole class of configuration mistake — a plugin injecting a timestamp, a source map path containing an absolute directory — that otherwise shows up as cache-busting nobody can explain.

The browser test suite is the assertion that the four false fallbacks were actually dead branches. Setting a needed module to false produces a runtime error rather than a build error, which is the one way this migration can go wrong quietly.

What this costs

The polyfill decisions are permanent configuration that encodes knowledge about six packages, and nothing re-checks it. A dependency update that starts genuinely using util hits the false and fails at runtime in a browser, with a message about an undefined function rather than a missing polyfill — which is why the fallback list deserves a comment per entry saying why, and why the browser tests are load-bearing rather than nice to have.

The filesystem cache introduces a category of problem that did not exist before: a stale cache producing a build that is wrong in a way no source file explains. buildDependencies covers the configuration and does not cover everything — an environment variable read at build time, a file loaded outside the module graph. The first response to an inexplicable build should now be deleting the cache directory, and that is a new thing for everybody to learn.