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.

Two Paths Through the Bootstrap The detection bootstrap runs in both browsers; the modern one finds no gaps and starts the application immediately, while the legacy one fetches a single merged polyfill chunk first. bootstrap: 0.4 KB feature tests, no syntax sugar Modern browser β€” 98% of sessions no gaps found Β· 0 requests Β· starts now Legacy browser β€” 2% of sessions gaps found Β· 1 request Β· 48 KB Β· then starts Application starts environment fully patched in both paths

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.

Constraints on the Bootstrap The file deciding whether polyfills are needed must parse in the oldest supported browser, import nothing, and stay small enough to be free. Three constraints, all load-bearing oldest-browser syntax one arrow function here breaks exactly the browsers it serves no dependencies anything it imports inherits the same constraint small enough to be free 0.4 KB against a 48 KB saving for 98% of sessions Wrapped in try/catch: a detection that throws must assume polyfills are needed
// 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.

Payload Before and After Conditional Loading Unconditional polyfills add 48 KB to every session. Conditional loading removes them from 98% of sessions and adds one request for the remainder. Initial JavaScript per session (gzipped) Unconditional app 96 KB + polyfills 48 KB Conditional β€” modern app 96 KB + bootstrap 0.4 KB β€” 98% of sessions Conditional β€” legacy same total, one extra round-trip 0 96 KB 150 KB The cost moves from every session to the small share that genuinely needs it

Step-by-step verification

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.