How to Target Baseline Browsers with esbuild and SWC

This guide covers the syntax side of Polyfills & Differential Serving, part of JavaScript Bundle Optimization & Code Splitting. Most modern toolchains no longer use Babel for transpilation: Vite uses esbuild for production syntax lowering, Next.js uses SWC, and Rspack, Turbopack, Parcel and others use one of the two. Each has its own notion of a "target", and getting it right determines whether your classes, async functions, optional chaining and object spread ship as written or as longer, slower emulations.

"Baseline" — the web platform's shared definition of features available across all major engines — gives a principled target. Baseline "widely available" features have been supported in every major browser for at least 30 months. Targeting browsers that support those features means virtually no syntax lowering for code written in modern JavaScript, while still covering the overwhelming majority of real traffic.

Same source, two targets Comparison of output characteristics for one source file compiled with an ES2015 target and with a modern Baseline target. Same source, two targets target: es2015 • async/await → generator state machines • Class fields → constructor assignments + helpers • ?. and ?? → ternary chains • ~18% larger, slower to parse target: Baseline browsers • Syntax emitted as written • No helper functions injected • Shorter code, faster parse • Native engine optimisations apply

Rapid Diagnosis

  • Check the current target. Vite: build.target; Next.js: browserslist in package.json (read by SWC); esbuild: --target; SWC standalone: jsc.target or env.targets in .swcrc.
  • Grep the output for lowering artefacts: __async, __publicField, __spreadValues (esbuild helpers) or _async_to_generator, _define_property (SWC helpers).
  • Compare with your analytics. Confirm the browsers in the target cover your traffic — and that you are not targeting far below what your traffic needs.
  • Check dependencies. Code in node_modules may not be transpiled at all; your target then affects only your own code.

Root Cause Analysis

1. Conservative defaults copied forward. Projects created when es2015 was the safe choice keep it indefinitely.

2. Targets expressed as ES versions instead of browsers. es2017 sounds modern, but forces lowering of everything newer (object spread, optional chaining, class fields), which all current browsers support.

3. Mismatch with Browserslist. esbuild ignores Browserslist; SWC reads it only when its own targets are unset. CSS and JS can end up targeting different browsers.

4. Fear of breaking old devices without data. Teams keep low targets because nobody has checked what visitors actually run.

Output size of one app by syntax target (minified, uncompressed) Bar chart of minified bundle size for the same application compiled with four syntax targets. Output size of one app by syntax target (minified, uncompressed) es2015 612KB es2017 571KB es2020 529KB Baseline browsers (2023+) 518KB

Step-by-Step Resolution

1. Express the target as browsers

javascript
// vite.config.js — esbuild needs explicit engine versions.
export default {
  build: { target: ['chrome111', 'edge111', 'firefox114', 'safari16.4'] },
};
// trade-off: hard-coded versions go stale. Generate them from Browserslist
// (browserslist-to-esbuild) so one policy file drives everything, and revisit
// the policy twice a year.
json
{
  "jsc": { "parser": { "syntax": "typescript", "tsx": true } },
  "env": { "targets": "chrome >= 111, edge >= 111, firefox >= 114, safari >= 16.4" }
}

Expected outcome: syntax supported by those engines is emitted untouched.

2. Keep Next.js and SWC on Browserslist

Next.js reads browserslist from package.json for SWC syntax lowering and polyfill decisions; its default already targets modern browsers. Make sure nothing overrides it.

json
{
  "browserslist": ["chrome 111", "edge 111", "firefox 114", "safari 16.4"]
}

Expected outcome: SWC output matches the same matrix as your CSS tooling. (JSON cannot carry a trade-off comment; the trade-off is the same as above — revisit the list as browsers age out.)

3. Handle runtime gaps separately from syntax

esbuild and SWC lower syntax; they do not polyfill APIs (SWC can inject core-js via env.mode, esbuild cannot). For the few APIs missing in your targets, add explicit, feature-detected polyfills.

javascript
// polyfills.js — imported first in the entry, only what the matrix lacks.
if (!Array.prototype.findLast) {
  Array.prototype.findLast = function (fn, thisArg) {
    for (let i = this.length - 1; i >= 0; i--) if (fn.call(thisArg, this[i], i, this)) return this[i];
  };
}
// trade-off: hand-written polyfills are small but not spec-complete. Use
// core-js modules for anything non-trivial (Intl, iterators, structured clone).

Expected outcome: syntax native, APIs present, no blanket polyfill library.

Where each toolchain reads its target Common esbuild- and SWC-based toolchains with where their syntax target is configured and whether they read Browserslist. Where each toolchain reads its target Toolchain Target setting Reads Browserslist Vite (esbuild) build.target no — convert it Next.js (SWC) package.json browserslist yes SWC standalone env.targets or jsc.target if env.targets unset esbuild CLI --target no Rspack (SWC loader) builtin:swc-loader env configurable

4. Verify dependencies are compatible with the target

Raising your target never breaks dependencies, but lowering it does not fix dependencies shipping newer syntax unless you transpile node_modules. Run your smoke tests in the oldest targeted engine to catch both kinds of problem.

Verification

Grep the output for helper names; with a Baseline target there should be few or none. Compare minified sizes before and after, and run a throttled trace on a key route: parse/compile time in the Bottom-Up view (look for "Compile Script" and "Parse" entries) should drop roughly in proportion to the size reduction. Run end-to-end tests in WebKit and Firefox at the oldest versions you target.

Common Mistakes

  • Using esnext as a target. It emits syntax that may not yet be supported anywhere (stage proposals in some setups). Target real browsers.
  • Setting the TypeScript target low while esbuild does the bundling. Vite ignores tsconfig target for output, but other toolchains may honour it; keep them consistent.
  • Forgetting decorators. Decorator syntax requires transformation in most targets; it is handled separately from the general target and can add helpers regardless.
  • Assuming smaller output means faster runtime everywhere. Native syntax is generally faster, but measure hot paths if you rely on heavily optimised legacy patterns.

Baseline as a Policy, Not Just a Target

Baseline is useful beyond build configuration because it gives product, design and engineering a shared vocabulary. "We use Baseline widely available features freely, newly available features with fallbacks, and anything else only behind feature detection" is a policy that applies equally to CSS (:has(), container queries), HTML (<dialog>, popover) and JavaScript APIs. Tools such as the baseline-browser-mapping data and linting rules can flag usage of non-Baseline features in code review, which keeps the target honest: there is little point setting a modern build target if the code quietly depends on features that need polyfills in half the matrix.

Worked Example: Vite Target Mismatch

A Vite application had build.target: 'es2015' set explicitly in its config — a leftover from a migration from webpack — while its Browserslist (used for CSS) was modern. The JavaScript output contained esbuild helpers for async functions, class fields and spread throughout, and was 14% larger than necessary. Replacing the explicit target with browserslistToEsbuild() aligned JavaScript with CSS, removed the helpers, and reduced compile time for the main chunk by roughly 20ms on a 4x-throttled trace. Because esbuild never adds polyfills, nothing else changed — a reminder that with esbuild, target and polyfills are separate decisions.

FAQ

Is esbuild's output as small as Terser's?

esbuild minification is close to Terser's — usually within a few percent — and far faster. The syntax target has a bigger effect on size than the choice of minifier for most codebases. See esbuild vs Terser for production minification.

What about class fields and older Safari?

Class fields (public and private) are supported in Safari 14.1+/15+ and all current engines. If your matrix includes older Safari, the target will cause lowering — correct, but a reminder to check whether that Safari version is actually in your traffic.

Does Next.js polyfill anything by default?

Next.js includes a small set of polyfills for older browsers via nomodule and polyfills certain APIs used by its runtime. Your own code's API polyfills are your responsibility; add them deliberately rather than importing a full polyfill library.