Polyfills & Differential Serving: Shipping Modern JavaScript to Modern Browsers
This topic belongs to JavaScript Bundle Optimization & Code Splitting. Every production bundle encodes an assumption about which browsers will run it. That assumption is expressed in three places — the Browserslist query, the transpiler's target, and the polyfill configuration — and when it is out of date, every visitor pays for the oldest browser you ever supported. A bundle targeting browsers from 2017 carries transpiled async functions, class helpers, regenerator runtime and dozens of core-js modules for APIs every current browser has had for years.
The cost is not only bytes. Down-levelled syntax is longer and slower to parse and execute than the native equivalent, and polyfills run feature checks and install shims at startup. On a mid-tier phone, an unnecessary 60KB of polyfills and helpers can add 40–80ms of parse and evaluation to the critical path — directly visible in Total Blocking Time and in the input delay of early interactions, both of which feed INP's 200ms budget.
The Metric Degradation This Topic Addresses
The symptom is a bundle that is larger and slower than its source suggests. Teams see it as:
- Unexplained bundle weight —
core-jsmodules,regenerator-runtimeand Babel helpers appearing among the largest modules in a treemap. - High TBT on simple pages — startup tasks evaluating polyfill installers and transpiled code before any application logic.
- Slow early interactions — input delay during the first seconds of load, because polyfill and framework evaluation occupy the main thread.
The thresholds are the standard ones — a lab TBT under roughly 200ms and field INP under 200ms at p75 — and polyfills are rarely the largest contributor, but they are among the cheapest to remove: a configuration change with no product trade-off for modern visitors.
Prerequisites
- Analytics with browser versions (or RUM with user-agent data) covering at least the last 30 days, to define the real support matrix.
- Access to the Browserslist configuration (
.browserslistrcor thebrowserslistkey inpackage.json) and to the transpiler config (Babel, SWC, esbuild/Vitebuild.target). - A bundle visualiser for before/after comparison.
- Agreement on support policy — a written statement such as "we support browsers covering 99% of our traffic, plus the last two versions of each major engine" that engineering, product and support share.
1. Environment Setup: Make the Support Matrix Explicit
# What does the current configuration actually target?
npx browserslist
npx browserslist --coverage=global "defaults"
# trade-off: global coverage numbers come from caniuse usage data, not your
# traffic. Use them for orientation; decide using your own analytics.
Write the agreed policy into Browserslist so every tool that reads it — Babel, PostCSS/Autoprefixer, core-js, SWC, Lightning CSS — uses the same matrix.
2. Capture a Baseline
Build with the current configuration and record total JavaScript, the size of polyfill and helper modules in the treemap, and lab TBT on two key routes with 4x CPU throttling. Keep these numbers; they are your before.
3. Isolate the Cost
Search the build output for the signatures of legacy targets: regeneratorRuntime, _classCallCheck, _asyncToGenerator, and core-js/modules/ imports. Their presence and volume tell you whether the transpile target, the polyfill configuration, or both are too conservative. Auditing core-js polyfill weight walks through this in detail.
4. Apply the Fix
- Tighten Browserslist from real data, as shown in removing unneeded polyfills with Browserslist.
- Use usage-based polyfilling (
useBuiltIns: 'usage'with an explicitcorejsversion) rather than importing all ofcore-js. - Raise the transpile target to native ES2020+ syntax for your matrix — see targeting baseline browsers with esbuild and SWC.
- Only if a real legacy audience remains, serve a separate legacy build to it, weighing the operational cost discussed in is module/nomodule differential serving still worth it?.
Deconstructing the Cost of Legacy Support
Legacy support costs time in each phase of script processing, and each phase has its own fix:
| Phase | Legacy cost | Fix | Typical saving (mid-tier mobile) |
|---|---|---|---|
| Download | polyfill + helper bytes | tighter matrix, usage-based polyfills | 20–60KB compressed |
| Parse/compile | longer down-levelled code | modern transpile target | 15–40ms |
| Evaluate | polyfill feature tests and installs | drop unneeded polyfills | 5–20ms |
| Execute | slower emulated features (e.g. generator state machines) | native syntax | varies with usage |
Advanced Diagnostics and Edge Cases
Third-party code with its own polyfills. Libraries sometimes bundle their own polyfills, which your configuration cannot remove. The treemap shows them under the library's path; prefer libraries that publish modern ESM and leave polyfilling to the application.
node_modules not transpiled. Many setups skip transpiling dependencies for speed, so a dependency shipping modern syntax works only in modern browsers regardless of your target. Raising your target does not change that; lowering it does not fix it unless dependencies are included in transpilation.
Polyfills needed for new APIs, not old ones. Modern features — Array.prototype.findLast, structuredClone, Promise.withResolvers, Intl APIs — may be missing in browsers that otherwise qualify as modern. Usage-based polyfilling handles these correctly only if corejs is set to a recent minor version.
CSS is part of the picture. Autoprefixer and Lightning CSS read the same Browserslist; an outdated matrix also bloats CSS with vendor prefixes and fallbacks, which affects render-blocking CSS size and therefore LCP.
Feature detection versus targeting. Some APIs cannot be polyfilled meaningfully (scheduler.yield() in old engines, content-visibility); for those, write code that degrades gracefully rather than relying on build-time polyfills.
Validation and Budgeting
Assert that legacy signatures do not reappear: a CI step that greps the built JavaScript for regeneratorRuntime and counts core-js modules prevents a dependency upgrade or a config change from silently reintroducing them.
# ci/check-legacy-output.sh
set -e
if grep -l "regeneratorRuntime" dist/assets/*.js; then echo "regenerator found — target too low"; exit 1; fi
COUNT=$(grep -o "core-js/modules/[a-z.]*" -h dist/assets/*.js.map 2>/dev/null | sort -u | wc -l)
echo "core-js modules: $COUNT"; [ "$COUNT" -le 15 ] || { echo "too many polyfills"; exit 1; }
# trade-off: grepping source maps for module paths works only when maps are
# generated in CI. If they are not, use the bundle visualiser's raw output.
Re-evaluate the support matrix twice a year against analytics. Browser populations shift steadily; a matrix set once and never revisited drifts back towards shipping legacy code to everyone.
Worked Example: Modernising a Three-Year-Old Storefront
A storefront built in 2022 used "browserslist": ["> 0.5%", "last 2 versions", "not dead", "ie 11"], Babel with useBuiltIns: 'entry' and import 'core-js/stable' in the entry. Its analytics for the previous 90 days showed no Internet Explorer sessions, 0.3% of sessions from Safari versions older than 15, and everything else on browsers released in the last three years.
The team adopted a 99.5% coverage policy and derived a new query from their stats. With IE gone and the oldest Safari now 15.4, Babel stopped lowering classes, async functions, spread and optional chaining. Switching to useBuiltIns: 'usage' with corejs: '3.40' cut the polyfills from over a hundred modules to nine, most of them for Array.prototype.at, Object.hasOwn and structuredClone, which a few Safari 15 versions lacked.
The results on the product template, measured on a throttled mid-tier profile: initial JavaScript down from 301KB to 238KB compressed, startup scripting time down 64ms, lab TBT down from 410ms to 330ms. Field INP p75 for early interactions fell by about 25ms over the following month — a modest gain on its own, but one that came from a configuration change with no product cost, and that cleared the way for the larger hydration work that followed.
Coordinating CSS, HTML and JavaScript Targets
The same Browserslist query drives Autoprefixer and Lightning CSS, so modernising it also trims CSS — vendor prefixes for flexbox and grid, -webkit- duplicates for long-standardised properties, and fallbacks for features like gap in flex layouts. Because CSS is render-blocking, those savings land on LCP rather than INP. Check the CSS output alongside the JavaScript treemap when you change the query; it is common to find 5–15% of a legacy stylesheet consisting of prefixes no supported browser needs.
HTML features deserve the same scrutiny. Native loading="lazy", <dialog>, the popover attribute and inert are now widely supported; JavaScript libraries that emulated them can often be removed entirely once the matrix is modern, which is a bigger saving than any polyfill configuration.
Dependencies: The Part of the Bundle You Do Not Compile
Most build setups exclude node_modules from transpilation for speed, which has two consequences people miss. First, raising your own target does nothing to dependencies that were published pre-compiled to ES5 with their own helpers — that code stays verbose. Second, lowering your target does nothing for dependencies published as modern ESM: they will still break old browsers. Audit the largest dependencies in your treemap for their published syntax level. Prefer packages that publish modern ESM (they can be lowered by your build if needed, and shake better), and for important dependencies that ship ES5 with bundled helpers, check whether a newer major version or an alternative publishes a modern build.
Transpiling a few specific dependencies is reasonable when they publish newer syntax than your oldest target supports; configure the bundler to include just those packages rather than all of node_modules, which multiplies build time for little benefit.
Measuring the Effect in the Field
Polyfill and target changes produce small per-page gains that are easy to lose in RUM noise. Compare like with like: segment by release, look at the startup-heavy metrics (INP for interactions in the first five seconds, and LCP on client-rendered templates), and use a few weeks of data on either side. Report the result as a range rather than a single number. If you ship the change behind a build flag to a fraction of traffic first, the comparison becomes far cleaner — the two populations share the same days, devices and content.
Writing a Support Policy That Sticks
Technical changes to targets fail when nobody owns the support decision. A short written policy — "We support the browser versions that together account for 99% of sessions over the last 90 days, re-evaluated every six months, with a minimum of the last two major versions of Chrome, Edge, Firefox and Safari" — turns a contentious debate into a mechanical check. Publish the resulting matrix to customer support so they know what to tell users on unsupported browsers, and add a lightweight unsupported-browser notice (feature-detected, not user-agent sniffed) rather than leaving those users with a silently broken page. The policy also protects performance in the other direction: when someone asks to support an old browser for one customer, the cost is visible and the decision is explicit.
FAQ
Is "defaults" in Browserslist a good choice?
It is a reasonable global default — browsers with over 0.5% global usage, the last two versions, Firefox ESR, and not dead — but it is based on global statistics, not your audience. Many sites' traffic is far more modern than the global mix; some (enterprise, certain regions) is older. Derive the query from your analytics.
Can I just remove all polyfills?
Only if your code uses no APIs missing from any supported browser. Usage-based polyfilling with a tight matrix gets you most of the way: it includes a polyfill only when your code uses a feature that some supported browser lacks. That number is usually small and occasionally zero.
Does this affect server-side rendering builds?
Server bundles run in Node, whose version you control, so they should target that Node version directly and include no browser polyfills. Make sure SSR and client builds use separate targets rather than one shared, conservative setting.
How do I communicate a support change to users on old browsers?
Feature-detect the capabilities your app needs ('noModule' in HTMLScriptElement.prototype, for example) and show a brief, server-renderable notice with upgrade guidance when they are missing. Avoid user-agent sniffing, which misidentifies embedded browsers and privacy-focused clients. Keep critical content readable without JavaScript so the notice is the only degradation.
Do polyfills affect Core Web Vitals directly or only lab scores?
Both, modestly. Polyfill download competes for bandwidth with LCP resources on slow connections, and polyfill evaluation adds to startup long tasks that delay early interactions — visible in field INP for interactions during load. The effect per page is usually tens of milliseconds, smaller than hydration or third-party costs, but it applies to every page view and is removed with configuration rather than refactoring, which makes it one of the best value changes available.
Should design systems and component libraries ship polyfills?
No. Libraries should publish modern code and document the platform features they rely on; the application decides which browsers to support and polyfills accordingly. A library that bundles its own polyfills forces every consumer to ship them, often duplicating the application's own copies.
Is it safe to rely on Baseline "newly available" features?
Newly available means the feature has just landed in the last of the major engines, so a share of visitors — those who have not updated yet — will not have it. Use such features with a fallback or feature detection for at least a year after they reach that status, or until your analytics show the remaining share is within your policy's tolerance. Widely available features, by contrast, can be used freely under a 99%-coverage policy.
Guides in This Topic
- Removing unneeded polyfills with Browserslist — derive the matrix from real traffic.
- Is module/nomodule differential serving still worth it? — when a legacy build earns its complexity.
- Auditing core-js polyfill weight — find and remove the polyfills you ship.
- Targeting baseline browsers with esbuild and SWC — modern syntax without Babel.
Related
- Tree shaking and dead code elimination — removing unused code once targets are right.
- Modern module formats: ESM vs CommonJS — module format choices that interact with targets.
- Vite & Rollup build optimization — where the target is configured in Vite builds.
- JavaScript performance budgets — keeping the savings in CI.