Conditionally Importing Polyfills at Runtime
Polyfills are the clearest case of code that most users download and none of them execute. A build that includes them unconditionally ships the same 30β60 KB of shims to every visitor, when the share of traffic that actually needs any of them is frequently under 2%.
The shrinking polyfill and transpilation overhead guide covers reducing that payload at build time, through target configuration. This page covers the complementary runtime technique: detect what is missing when the page loads, and fetch a polyfill chunk only for the browsers that are missing it.
The symptom that motivates it looks like this in an analyzer treemap:
core-js modules 48.3 kB ββββββββββββββββ
ββ es.array.flat 2.1 kB
ββ es.object.from-entries 1.4 kB
ββ es.promise.all-settled 1.9 kB
ββ 41 further modules 42.9 kB
Forty-four shims in the main chunk, on the critical path, for features every browser in the analytics report already supports.
Root cause: the build cannot know which browser will load the page
A bundler applies one configuration to one output. If the browser support matrix includes anything that lacks a feature, the polyfill for it is included for everyone β the build has no way to vary its output per visitor, because it happens once, long before any visitor arrives.
Runtime detection moves the decision to the only place that has the answer. The cost is one conditional round-trip on the legacy path; the saving is the entire polyfill payload on every other session.
The bootstrap
The detection code must run before the application and inside the oldest browser you support, which constrains it: no modern syntax, no dependencies, and small enough that the extra file costs nothing.
// bootstrap.js β runs first, in every browser, before the app entry.
// Deliberately written in ES5-compatible syntax: this file is the one thing
// that cannot itself depend on the features it is testing for.
function needsPolyfills() {
try {
// Test capabilities, never user agent strings.
return !(
Array.prototype.flat &&
Object.fromEntries &&
Promise.allSettled &&
typeof globalThis === 'object' &&
typeof structuredClone === 'function'
);
} catch (e) {
return true; // an exception during detection means assume the worst
}
}
function start() {
// Named chunk so the app entry is identifiable in the network panel.
return import(/* webpackChunkName: "app" */ './main.js');
}
if (needsPolyfills()) {
// One chunk, one request β see the grouping note below.
import(/* webpackChunkName: "polyfills" */ './polyfills.js').then(start);
} else {
start();
}Two properties are essential. The polyfill import is awaited before the application entry runs, so no module ever evaluates against a half-patched environment. And the detection is wrapped in a try/catch, because a test that throws in an unusual environment must fall back to loading the polyfills rather than crashing the bootstrap.
Grouping the polyfills into one chunk
A browser missing Array.prototype.flat is usually missing several other things too. Importing each polyfill separately turns one request into six, on precisely the old, slow devices least able to absorb round-trips.
// polyfills.js β one module, one chunk, every shim this build supports
// Imported only from the bootstrap's conditional branch, so it is never
// reachable from the main entry and never lands in the initial chunk.
import 'core-js/es/array/flat';
import 'core-js/es/object/from-entries';
import 'core-js/es/promise/all-settled';
import 'core-js/es/global-this';
import './structured-clone-shim';The chunk-naming mechanics behind that merge are covered in using webpackChunkName magic comments effectively. In Vite, the equivalent is a manualChunks rule assigning everything under the polyfill directory to one chunk:
// vite.config.js β Vite 5+
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/src/polyfills') || id.includes('node_modules/core-js')) return 'polyfills';
},
},
},
},
};Keeping the build honest
Runtime detection only helps if the build stops injecting polyfills into the main chunk. If the transpiler is still configured to inject them automatically based on usage, both mechanisms run and the payload is unchanged.
// babel.config.js β polyfills are handled at runtime, not injected here
module.exports = {
presets: [
['@babel/preset-env', {
// 'entry' or 'usage' would inject core-js imports into application
// modules, defeating the conditional bootstrap entirely.
useBuiltIns: false,
bugfixes: true,
}],
],
};The browser targets that remain in your configuration still control syntax transpilation, which is a separate concern from polyfills and is addressed in configuring Browserslist to drop legacy polyfills.
Step-by-step verification
-
Confirm the modern path fetches nothing. In a current browser with an empty cache, the polyfill chunk must not appear in the network panel at all.
-
Confirm the legacy path fetches once. Force detection to fail by overriding one tested global before the bootstrap runs, and confirm exactly one polyfill chunk is requested.
-
Confirm ordering. With detection forced, verify no application module evaluates before the polyfill chunk resolves β a console log at the top of the entry should appear after the polyfill request completes.
-
Confirm the main chunk is clean. Search the built main chunk for shim source. Any hit means the transpiler is still injecting polyfills despite the runtime path.
-
Test the detection in the oldest supported browser. The bootstrap itself must parse there. A single arrow function in that file breaks exactly the browsers it exists to serve.
-
Watch the field split. Report which path each session took, using the instrumentation in measuring real-user chunk loading performance, and confirm the legacy share matches your analytics.
Edge cases and gotchas
Partial implementations. Some environments define a method that does not behave correctly. A presence check passes while behaviour is still wrong, so for known-broken implementations the test must exercise behaviour, not just existence.
Polyfills needed by the bootstrapβs own dependencies. Anything the bootstrap imports statically is subject to the same constraints as the bootstrap. Keep it dependency-free.
Service worker caching the wrong path. A worker that caches the application shell can serve a modern-path response to a legacy browser. Include the polyfill decision in the cache key, or make the workerβs precache path-agnostic β see invalidating service worker cache after deploy.
Server-rendered markup expecting patched globals. If server-rendered HTML was produced assuming a feature that the client polyfills, hydration can diverge on the legacy path. Keep the server output free of environment-dependent formatting.
FAQ
Why detect features instead of reading the user agent?
Because the user agent string is a claim about identity and a feature test is a measurement of capability. Browsers spoof, freeze, and truncate their user agent strings, embedded webviews report their host application, and support for a given feature does not track version numbers cleanly across forks. A feature test asks exactly the question you care about, needs no maintenance as new versions ship, and cannot be wrong about the environment it is running in.
Does a conditional polyfill import delay first paint?
For browsers that need it, yes β one extra round-trip before the application starts, which is the price of correctness, since starting before the environment is patched produces errors that are worse than the delay. For browsers that do not need it, no: the detection resolves immediately and no request is made at all. That asymmetry is the entire point, because it puts the cost on the small population that requires it instead of on everyone.
Should polyfills be one chunk or one chunk per feature?
One chunk, in nearly all cases. A browser missing one modern feature is usually missing several, so per-feature chunks turn one request into five or six on exactly the slow, old devices least able to absorb the round-trips. Give every conditional import the same chunk name and the bundler merges them, so the legacy path costs one request regardless of how many gaps were detected.
Related
- Dynamic Import Patterns for On-Demand Loading β the parent guide on conditional and deferred imports
- Using webpackChunkName Magic Comments Effectively β the grouping mechanism that keeps polyfills to one request
- Shrinking Polyfill and Transpilation Overhead β the build-time half of the same problem
- Configuring Browserslist to Drop Legacy Polyfills β narrowing targets so fewer shims exist at all