esbuild 09 ⚡ The target Option — Downleveling syntax for older browsers
The target option tells esbuild which JavaScript environments the output must run in. It does not change what the code does; it changes how the code is written. A nullish coalescing operator (??) becomes a ternary check. An optional chaining expression (?.) becomes a nested conditional. An arrow function becomes a function expression. The transformations are mechanical, and they are applied only when the target environment does not support the syntax natively.
This chapter covers the target option’s value syntax, the distinction between syntax lowering and API availability, the order-dependence bug when multiple targets for the same engine are specified, and the practical consequence that esbuild does not support ES5 as a target.
Key point: target controls syntax downleveling, not API polyfilling. A Promise or fetch call is not transformed by target; those are runtime APIs that require a polyfill or a different environment. The target option only decides whether ?., ??, async/await, and similar syntax constructs are rewritten.
Why target exists
The compatibility problem. The JavaScript language evolves. A feature that is native in Chrome 90 may be unsupported in Safari 13. A build that outputs the latest syntax will fail to parse in an older browser, and the failure is a syntax error, not a runtime error. The target option is the mechanism that tells esbuild which syntax the output must avoid .
The size problem. Downleveling has a cost. An async function becomes a state machine when compiled to ES2015. An arrow function becomes a function expression with a this binding. The output is larger and sometimes slower. A build that targets ES2015 for an environment that supports ES2020 pays that cost unnecessarily. The target option is a trade-off between compatibility and output efficiency.
The ES5 limitation. esbuild does not support ES5 as a target. The documentation and the API constants confirm that the minimum is ES2015 . A project that needs ES5 output must use a different tool or an additional transformation step. This is a deliberate design decision: ES5 requires helper functions and runtime shims that conflict with esbuild’s model of generating self-contained output.
a. The target value syntax
The target option accepts a single string or an array of strings. Each string is either an ECMAScript version (es2015, es2020, esnext) or a browser name with a version (chrome87, firefox78, safari14, edge88) .
// ECMAScript version
esbuild.build({ target: 'es2020' })
// Browser version
esbuild.build({ target: 'chrome87' })
// Multiple targets
esbuild.build({ target: ['chrome87', 'firefox78', 'safari14'] })
When multiple targets are specified, esbuild applies the most conservative transformation. If one target does not support optional chaining and another does, the output is downleveled for the target that does not. The goal is compatibility with every specified environment, not just one of them.
The esnext value disables downleveling entirely. The output preserves whatever syntax the source uses. This is appropriate for a build that targets only modern environments or for a two-stage build where a second tool performs the downleveling.
b. The order-dependence bug
There is a known issue in esbuild’s handling of multiple targets for the same engine. When the target list contains two versions of the same browser, esbuild uses the last version specified, not the lowest version .
// chrome47 supports neither ?? nor ?.
// chrome147 supports both.
// esbuild uses chrome147 (the last one) and does NOT downlevel.
esbuild.build({ target: ['chrome47', 'chrome147'] })
This is a bug, not a feature. The expected behavior is that the lowest version for each engine wins, because the output must be compatible with every listed environment. The workaround is to order the list from highest to lowest, so that the lowest version is last and therefore takes effect. A more robust workaround is to ensure that the target list does not contain duplicate engines.
c. Syntax lowering versus API availability
The target option affects syntax. It does not affect the availability of runtime APIs. A build with target: 'es2015' will downlevel async/await, but it will not provide a Promise polyfill. If the target environment does not have Promise, the output will fail at runtime even though the syntax is valid ES2015.
The distinction matters for the choice of target. A browser that supports ES2015 syntax may not support fetch or Intl. The target option does not solve those problems. A polyfill or a library like core-js is required for API availability, and that is outside esbuild’s scope.
Complete Example Session
// ============================================
// PART 1: ES2020 TARGET
// ============================================
esbuild.build({
entryPoints: ['app.js'],
target: 'es2020',
outfile: 'out.js',
})
// Optional chaining and nullish coalescing are preserved.
// The output uses the same syntax as the source.
// ============================================
// PART 2: ES2015 TARGET
// ============================================
esbuild.build({
entryPoints: ['app.js'],
target: 'es2015',
outfile: 'out.js',
})
// Arrow functions become function expressions.
// async/await becomes a state machine.
// Optional chaining becomes conditional checks.
// ============================================
// PART 3: BROWSER TARGET
// ============================================
esbuild.build({
entryPoints: ['app.js'],
target: 'chrome87',
outfile: 'out.js',
})
// The output is compatible with Chrome 87.
// Syntax introduced after Chrome 87 is downleveled.
// ============================================
// PART 4: MULTIPLE TARGETS
// ============================================
esbuild.build({
entryPoints: ['app.js'],
target: ['chrome87', 'firefox78', 'safari14'],
outfile: 'out.js',
})
// The output is compatible with all three.
// Any syntax unsupported by any of them is downleveled.
// ============================================
// PART 5: ESNEXT TARGET
// ============================================
esbuild.build({
entryPoints: ['app.js'],
target: 'esnext',
outfile: 'out.js',
})
// No syntax lowering.
// The output preserves the source syntax.
// ============================================
// PART 6: THE ES5 LIMITATION
// ============================================
esbuild.build({
entryPoints: ['app.js'],
target: 'es5',
outfile: 'out.js',
})
// Error: Invalid target "es5"
// esbuild does not support ES5.
// The minimum is es2015.
// ============================================
// PART 7: THE ORDER-DEPENDENCE BUG
// ============================================
esbuild.build({
target: ['chrome47', 'chrome147'],
})
// The last version wins: chrome147.
// chrome47's lack of support is ignored.
// This is a known bug.
// ============================================
// PART 8: THE WORKAROUND
// ============================================
esbuild.build({
target: ['chrome147', 'chrome47'],
})
// The last version is chrome47.
// The output is downleveled for chrome47.
// ============================================
// PART 9: THE VITE INTEGRATION
// ============================================
// vite.config.js
export default {
build: {
target: 'es2015',
},
}
// Vite passes this to esbuild.
// The target must be a valid esbuild target.
// ============================================
// PART 10: THE DEFAULT
// ============================================
// The default target is esnext.
// No downleveling happens unless a target is set.
The ten parts covered the ES2020 target, the ES2015 target, the browser target, multiple targets, the esnext target, the ES5 limitation, the order-dependence bug, the workaround, the Vite integration, and the default.
Quick Reference
Target values
| Value | Meaning |
|---|---|
es2015 | ES2015 (minimum supported) |
es2016 | ES2016 |
es2017 | ES2017 |
es2018 | ES2018 |
es2019 | ES2019 |
es2020 | ES2020 |
es2021 | ES2021 |
es2022 | ES2022 |
esnext | No downleveling |
chrome87 | Chrome 87 |
firefox78 | Firefox 78 |
safari14 | Safari 14 |
edge88 | Edge 88 |
Target behavior
| Target | Optional chaining | Async/await |
|---|---|---|
es2020 | Preserved | Preserved |
es2017 | Preserved | Preserved |
es2015 | Downleveled | Downleveled |
esnext | Preserved | Preserved |
Limitations
| Limitation | Detail |
|---|---|
| ES5 not supported | Minimum is es2015 |
| API polyfills not included | target affects syntax only |
| Order-dependence bug | Last target for an engine wins |
Best Practices
✅ Do This:
// Use the oldest environment the app must support
target: 'es2015' // ✅
// Use browser targets for precise compatibility
target: ['chrome87', 'firefox78', 'safari14'] // ✅
// Order duplicate engines from highest to lowest
target: ['chrome147', 'chrome47'] // ✅
// Use esnext for a build that will be downleveled later
target: 'esnext' // ✅
❌ Don’t Do This:
// Use es5 as a target
target: 'es5' // ❌ (not supported)
// Assume target provides polyfills
target: 'es2015' // does not polyfill Promise // ❌
// List duplicate engines in ascending order
target: ['chrome47', 'chrome147'] // ❌ (bug)
// Leave the default when targeting older browsers
// The default is esnext // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Invalid target error | es5 or an unknown string | Use es2015 or a browser target |
| Older browser fails to parse | Target not set; default is esnext | Set the target to the oldest supported environment |
| Output is larger than expected | Downleveling to an unnecessarily low target | Raise the target to the actual minimum |
Promise is not defined | Target affects syntax, not APIs | Add a polyfill |
| Syntax not downleveled despite a low target | Duplicate engine in the list; last one wins | Remove duplicates or order highest to lowest |
Real-World Examples
1. ES2020 target
esbuild.build({ target: 'es2020' })
2. ES2015 target
esbuild.build({ target: 'es2015' })
3. Browser target
esbuild.build({ target: 'chrome87' })
4. Multiple targets
esbuild.build({ target: ['chrome87', 'firefox78'] })
5. esnext
esbuild.build({ target: 'esnext' })
6. Vite target
build: { target: 'es2015' }
7. Duplicate engine order
target: ['chrome147', 'chrome47'] // lowest last
8. The error
target: 'es5' // invalid
9. Syntax vs API
// target: 'es2015' downlevels async/await
// but does not provide Promise
10. The default
// No target specified → esnext
Visual
The downleveling decision
┌─────────────────────────────────────────────────────────────┐
│ TARGET: es2020 │
│ │
│ Source: a ?? b │
│ ?? is supported in ES2020 │
│ Output: a ?? b (unchanged) │
│ │
│ TARGET: es2015 │
│ │
│ Source: a ?? b │
│ ?? is NOT supported in ES2015 │
│ Output: a !== null && a !== void 0 ? a : b │
└─────────────────────────────────────────────────────────────┘
The duplicate engine bug
┌─────────────────────────────────────────────────────────────┐
│ target: ['chrome47', 'chrome147'] │
│ │
│ chrome47: no ??, no ?. │
│ chrome147: supports ?? and ?. │
│ │
│ Last wins: chrome147 │
│ Output: NOT downleveled │
│ Result: fails in chrome47 │
│ │
│ target: ['chrome147', 'chrome47'] │
│ │
│ Last wins: chrome47 │
│ Output: downleveled │
│ Result: works in both │
└─────────────────────────────────────────────────────────────┘
Syntax vs API
┌─────────────────────────────────────────────────────────────┐
│ TARGET AFFECTS TARGET DOES NOT AFFECT │
│ │
│ Arrow functions Promise │
│ Async/await fetch │
│ Optional chaining Array.prototype.includes │
│ Nullish coalescing Object.assign │
│ Classes Intl │
│ │
│ These require a polyfill, not a target. │
└─────────────────────────────────────────────────────────────┘
The ES5 boundary
┌─────────────────────────────────────────────────────────────┐
│ es5 ──► INVALID (minimum is es2015) │
│ es2015 ──► valid │
│ es2016 ──► valid │
│ ... │
│ esnext ──► valid │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Purpose | Downlevel syntax for older environments |
| Value types | ECMAScript version or browser version |
| Multiple targets | Conservative (most restrictive) |
esnext | No downleveling |
| ES5 support | None (minimum es2015) |
| API polyfills | Not provided by target |
| Duplicate engine bug | Last version wins |
| Default | esnext |
Key takeaways:
targetcontrols syntax downleveling, not API availability. It rewrites?.,??,async/await, and similar constructs for older environments. It does not providePromise,fetch, orArray.prototype.includes. Those require a polyfill .- ES5 is not a valid target. The minimum is
es2015. A project that needs ES5 must use a different tool or a separate transformation step. This is a deliberate limitation, not a bug . - The duplicate engine bug is real. When multiple versions of the same browser are specified, esbuild uses the last one, not the lowest. The workaround is to order the list from highest to lowest, so the lowest version is last .
esnextdisables downleveling. The output preserves the source syntax. This is appropriate for a two-stage build or for environments that support the latest syntax.- The default is
esnext. A build that does not settargetwill not downlevel anything. A project targeting older browsers must set the target explicitly. - Browser targets are more precise than ECMAScript versions. A browser target like
chrome87encodes the exact syntax support of that browser, which may differ from the ECMAScript version it claims to support.
Remember: The target option is a compatibility decision. It trades output size and readability for the ability to run in older environments. Set it to the oldest environment the application must support, use browser targets when precision matters, and remember that syntax is only half the problem: API availability is a separate concern that requires a polyfill. The duplicate engine bug is a workaround, not a feature; avoid listing multiple versions of the same browser unless the order is deliberate.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!