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.
Rapid Diagnosis
- Check the current target. Vite:
build.target; Next.js:browserslistinpackage.json(read by SWC); esbuild:--target; SWC standalone:jsc.targetorenv.targetsin.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_modulesmay 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.
Step-by-Step Resolution
1. Express the target as browsers
// 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.
{
"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.
{
"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.
// 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.
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
esnextas a target. It emits syntax that may not yet be supported anywhere (stage proposals in some setups). Target real browsers. - Setting the TypeScript
targetlow while esbuild does the bundling. Vite ignorestsconfigtarget 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.
Related
- Removing unneeded polyfills with Browserslist — the policy that should drive these targets.
- Configuring manualChunks in Vite — the other half of a lean Vite build.
- Measuring JavaScript parse and compile cost — confirming the parse-time gains.