We expected the polyfills to come from the framework. They did not. We installed 21 mainstream JavaScript toolchains on a clean machine, one npm install each, and counted every package in the resolved tree that exists to reimplement a JavaScript built-in the runtime already has. Next.js, Vite, webpack, React, Jest, TypeScript, Tailwind, Storybook and ESLint itself pulled in exactly zero. The lint configuration Next.js documents pulled in 86.
TL;DR
- We installed 21 toolchains on Node 22.22.0 with npm 10.9.4 and counted shim packages in each resolved dependency tree. Nine mainstream build tools pulled in zero.
eslint-config-next16.3.8 resolves to 234 packages. 86 of them, 37 per cent of the tree, are reachable only through its cluster of built-in reimplementations.eslint-plugin-import2.32.0 is the worst offender we measured: 125 packages, 96 of which exist only to serve the shims. That is 77 per cent of the tree.- ESLint 9 declares
engines.node: "^18.18.0 || ^20.9.0 || >=21.1.0". The shim packages beneath it declare">= 0.4", a Node release from 2011. Every built-in they patch has been native since Node 12 and in every major browser since January 2020 at the latest. - The cost is not bundle size. It is 3,780 extra files, 86 extra entries in your SBOM, and 84 of those 86 packages published under a single npm account.
- Swapping
eslint-plugin-importfor the maintainedeslint-plugin-import-xfork takes the tree from 125 packages to 17, with zero shims.
What we actually did
On 3 and 4 October 2026 we built 21 throwaway projects on a single Linux box running Node 22.22.0 and npm 10.9.4. Each got npm init -y followed by one npm install of a single mainstream package: next, vite, webpack, react, eslint, jest, typescript, tailwindcss, storybook, axios, express, cypress, jsdom, aws-sdk, @babel/preset-env, react-scripts, @wordpress/scripts, eslint-plugin-import, eslint-plugin-react, eslint-config-airbnb and eslint-config-next.
Then we parsed each package-lock.json, walked the tree from the root’s declared dependencies, and flagged every package published under the es-shims GitHub organisation plus object-assign, object.assign, globalthis and es-abstract. Then we re-walked the tree with those removed and counted what became unreachable. That second number is the honest one: it is the set of packages that would disappear from your install if the shims were replaced by the native calls they wrap.
The nine that were clean
Nine of the 21 came back at zero. next 16.3.8 resolves to 54 packages and not one of them reimplements a built-in. vite resolves to 40. typescript to 21. tailwindcss to a single package. eslint on its own resolves to 75 packages across 1,181 files, and is also clean.
This is worth saying plainly, because the received wisdom in agency circles is that modern JavaScript tooling is bloated with legacy compatibility code. On the evidence of this measurement, the build tools have been quietly cleaning house. The problem has moved somewhere less obvious.
The lint config everyone installs
eslint-config-next is the ESLint configuration Next.js ships and documents. In the week to 1 October 2026 it was downloaded 40,930,374 times against 74,675,385 for next itself, so roughly 55 per cent of Next.js installs take it. It resolves to 234 packages. Twenty-six of those are built-in reimplementations. Another 60 exist purely to support them. Remove the shims and 86 packages, 3,780 files and 4.83 MB fall out of the tree.
The chains are short and easy to verify yourself:
eslint-config-next -> eslint-plugin-import -> array-includes@3.2.0
eslint-config-next -> eslint-plugin-import -> object.values@1.2.1
eslint-config-next -> eslint-plugin-import -> string.prototype.trimend@1.0.10
eslint-config-next -> eslint-plugin-react -> object.entries@1.1.9
eslint-config-next -> eslint-plugin-react -> prop-types -> object-assign@4.1.1
eslint-config-next -> eslint-plugin-jsx-a11y -> jsx-ast-utils -> object.assign@4.1.7
object-assign@4.1.1 was published on 16 January 2017 and has not been touched since. It wraps Object.assign, which shipped in Edge 12, Firefox 34, Chrome 45 and Safari 9, the last of those on 30 September 2015. It is still downloaded 211,320,111 times a week.
Isolate the plugin and the ratio gets worse. eslint-plugin-import 2.32.0 on its own resolves to 125 packages. Ninety-six of them, 77 per cent, are unreachable once the shim cluster is removed.
The floor argument
Here is the part that makes this dead code rather than defensive engineering. We read the engines field of every package in the tree.
| Package | Declared engines.node |
|---|---|
eslint 9.39.5 |
^18.18.0 || ^20.9.0 || >=21.1.0 |
eslint-plugin-import 2.32.0 |
>=4 |
eslint-plugin-react 7.37.5 |
>=4 |
es-abstract 1.24.2 |
>= 0.4 |
array-includes 3.2.0 |
>= 0.4 |
globalthis 1.0.4 |
>= 0.4 |
ESLint will refuse to start on anything older than Node 18.18. The shims beneath it are written to survive Node 0.4, released in 2011. There is no install in which both conditions hold, so the fallback branches in those packages cannot execute. We confirmed the other half of the claim by running the runtime rather than reading about it: on Node 22.22.0, all fourteen built-ins in question, from Object.assign through Object.groupBy and Iterator.prototype.map, are present natively.
Browser support is not the justification either. Pulling the dates out of MDN’s browser compatibility data, the most recently supported feature in the set is globalThis, and the last major browser to ship it did so on 15 January 2020. Object.assign has been universal for eleven years. We covered the broader version of this shift in our guide to the browser APIs that replace your JavaScript libraries, but that post was about dependencies you choose. These are dependencies you do not.
What it costs, and what it does not
We should be precise about the damage, because the obvious claim is wrong. This is not a bundle size story. These are development dependencies. They never reach a browser, and they are tiny: the full 86-package cluster is 4.83 MB on disk. Swapping eslint-plugin-import for its lean fork saved us 2.76 MB, which no one will notice.
The cost is surface area. Those 86 packages are 3,780 files that your scanner walks, 86 rows in your software bill of materials, 86 entries your security team is nominally attesting to, and 86 ways for a compromised publish to land in your CI runner with write access to the workspace. The eslint-config-next tree is 11,583 files across 264 directories. Plain eslint is 1,181 files.
And the concentration is real. We queried the npm registry for the maintainers of all 86 packages. They resolve to 14 distinct npm accounts, and 84 of the 86 sit under one of them. To be completely clear, that account belongs to a long-standing TC39 contributor whose work is careful and well regarded, and nothing here suggests otherwise. The point is structural, not personal: a single credential compromise at one account would reach 84 packages inside the default lint tree of the most popular React framework. That is exactly the shape of risk a supply chain review should surface, and in our experience auditing client repositories it rarely does, because nobody reads past the top-level devDependencies.
What to do on Monday
First, measure your own tree rather than trusting ours. From any project root:
npm ls --all --parseable 2>/dev/null \
| sed 's|.*node_modules/||' | sort -u \
| grep -E '^(object|array|string|reflect|regexp)\.|^(object-assign|globalthis|es-abstract|core-js)'
Second, if eslint-plugin-import appears, look at eslint-plugin-import-x. It is the maintained fork, version 4.17.1, published 28 June 2026, at 8,176,981 downloads a week. In our test it resolved to 17 packages with zero shims, against 125 for the original. The rule names are compatible for the common cases, so for most codebases it is a config rename and a lockfile regeneration.
Third, treat development dependencies as production supply chain. A package that runs in CI with repository credentials in the environment is production, whatever the package.json section says.
Fourth, do not generalise from this into a purge. Some shims are load-bearing. core-js under @babel/preset-env is doing a real job if you genuinely support old browsers, and whatwg-url under jsdom is a spec-compliance tool rather than a polyfill. The ones worth removing are the ones whose only purpose is a branch that your declared minimum runtime makes unreachable.
The broader pattern
The interesting thing is where the legacy code was hiding. Everybody audits the framework. Nobody audits the linter, because a linter feels like a tool rather than a dependency. It is both, and in the case we measured it was the single largest contributor of legacy code to the tree by a wide margin.
REPTILEHAUS runs this kind of dependency and supply chain audit as part of our DevOps and platform engineering work, usually as the unglamorous first week of a build. If your lockfile has not been looked at properly since the project started, or you want a second opinion on what your CI pipeline is actually trusting, get in touch.
Measurements taken 3 and 4 October 2026 on Node 22.22.0 with npm 10.9.4. Download figures are npm registry counts for the week ending 1 October 2026. Browser support dates from MDN browser compatibility data. Package counts are production dependencies reachable from the project root.
📷 Photo by Ricardo Gomez Angel on Unsplash


