Differential Serving & Legacy Code

The Baseline Era: Moving Beyond module/nomodule

The module/nomodule pattern — where <script type="module"> loads modern ES6+ code and <script nomodule> loads a transpiled ES5 fallback — was the original mechanism for differential serving. In theory, browsers self-select the bundle they can handle. In practice, the approach has well-documented issues: several older browser versions (including Edge 18 and some Safari releases) download both bundles, sometimes executing both, actually harming performance. With IE 11 end-of-life in June 2022 and the ES modules baseline now at 97%+ global browser support, the practical need for a nomodule ES5 fallback has effectively disappeared for most applications.

The industry has moved to a simpler model: target a modern baseline and drop legacy support entirely. Vite 7 (June 2025) formalized this by changing its default build.target from 'modules' to 'baseline-widely-available' — a new concept aligned with the Web Platform Baseline initiative. Baseline Widely Available means the feature has been interoperable across Chrome, Edge, Firefox, and Safari for at least 30 months. For Vite 7/8, this resolves to chrome111, edge111, firefox114, and safari16.4 as minimum versions. Features that previously required transpilation — optional chaining, nullish coalescing, top-level await, private class fields, Array.at(), structuredClone() — now ship untransformed to production, reducing bundle size and improving runtime performance.

Frameworks have aligned with this direction. Angular only targets modern browsers and produces ESM-only builds. Remix intentionally serves no JavaScript to browsers that don’t support ES modules, falling back to server-side functionality. SvelteKit uses dynamic imports without fallback. Next.js uses nomodule only for polyfills, not for full bundle duplication.

Resources:

Browserslist and the Baseline Queries

Browserslist remains the standard way to declare your target browsers across the toolchain — it’s consumed by Babel (@babel/preset-env), Autoprefixer, PostCSS, Stylelint, and Lightning CSS. As of 2025, Browserslist now supports Baseline queries natively, providing a clean way to align your entire build pipeline with the Baseline initiative:

{
  "browserslist": ["baseline widely available"]
}

This query targets browser versions that support all Baseline Widely Available features (30+ months of cross-browser support). You can also use "baseline newly available" for more aggressive targeting (features interoperable today), or year-specific queries like "baseline 2024" for a specific feature set. The with downstream suffix includes Chromium-based browsers beyond Chrome/Edge (Samsung Internet, Opera, Brave, etc.):

{
  "browserslist": ["baseline widely available with downstream"]
}

For sites with analytics data, Browserslist can query against your actual user statistics. The browserslist-plausible package downloads browser stats from Plausible analytics, letting you use queries like "> 0.5% in my stats". This is the most data-driven approach — you target exactly the browsers your users actually use. Run npx browserslist in your project directory to see the resolved browser list, and use npx update-browserslist-db periodically to keep the underlying caniuse-lite data current.

Resources:

Polyfill Strategies for 2026

Vite handles syntax transforms (transpiling newer syntax to older equivalents) via Oxc Transformer, but it deliberately does not include runtime polyfills (implementations of new APIs like Array.groupBy(), Promise.withResolvers(), Set methods, etc.) by default. If your target browsers need API polyfills, you have several options:

@vitejs/plugin-legacy is the official Vite plugin for legacy browser support. It uses @babel/preset-env with useBuiltIns: 'usage' to detect which polyfills your code actually needs and injects only those, using core-js as the polyfill source. The plugin generates legacy chunks with <script nomodule> and modern chunks with <script type="module">. For modern-only projects that still need a few API polyfills, you can use it with renderLegacyChunks: false and modernPolyfills: true to add polyfills to the modern build only, without generating a legacy bundle at all. Be cautious with automatic polyfill detection: core-js is aggressive, and even targeting native ESM can result in 15KB of unnecessary polyfills.

Polyfill.io alternatives — the original Polyfill.io service was compromised in a supply chain attack in June 2024 and should not be used. The Cloudflare-hosted alternative at cdnjs.cloudflare.com/polyfill/ provides the same UA-based polyfill serving from a trusted source. However, the trend has shifted away from runtime UA-based polyfilling toward build-time approaches that are more deterministic and don’t add an extra network dependency.

Manual polyfills — for the most control, import specific polyfills only where needed. Packages like core-js can be imported granularly: import 'core-js/actual/array/group-by'. This avoids loading polyfills globally and gives you exact control over what ships to production. For new APIs that are close to universal support, consider checking browser support on caniuse.com and simply using the native implementation with a graceful degradation fallback.

Resources:

Identifying and Retiring Legacy Code

As your target baseline moves forward, code that was once necessary becomes dead weight — polyfills for features now universally supported, workarounds for browser bugs that no longer exist, and transpilation overhead for syntax that all target browsers handle natively. Proactively identifying and removing this code is a free performance optimization.

Knip (covered in Section 5) identifies unused dependencies and exports across your project. Browserslist query updates can reveal that polyfills you’re shipping are no longer needed — compare your current browserslist output against the features you’re polyfilling. Lighthouse flags legacy JavaScript that could be avoided with the module/nomodule pattern, though this audit is increasingly less relevant as the “legacy” tier shrinks.

For teams with large codebases that have accumulated technical debt, adopt an incremental approach. Set up metrics that track the ratio of legacy code (polyfills, compatibility shims, IE-specific branches). Publicly discourage use of deprecated patterns in code reviews and CI alerts. Use the TypeScript 6.0 “bridge release” (which deprecates features removed in TypeScript 7.0) as an opportunity to clean up — it deprecates target: "es5", removes baseUrl, and removes node10 module resolution. These forced migrations are healthy pressure to modernize.

Node.js 22.18+ (July 2025) now supports native TypeScript execution via type stripping — you can run .ts files directly with node file.ts without any build step. This eliminates an entire class of build complexity for server-side code, scripts, and tooling. Combined with tsgo for type-checking, the trend is clear: the JavaScript ecosystem is rapidly shedding the layers of compilation and transpilation that accumulated over the ES5 era.

Resources: