| | |

esbuild 09 ⚡ The target Option — Downleveling syntax for older browsers

⏱️ Estimated read: 9 min | |

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

ValueMeaning
es2015ES2015 (minimum supported)
es2016ES2016
es2017ES2017
es2018ES2018
es2019ES2019
es2020ES2020
es2021ES2021
es2022ES2022
esnextNo downleveling
chrome87Chrome 87
firefox78Firefox 78
safari14Safari 14
edge88Edge 88

Target behavior

TargetOptional chainingAsync/await
es2020PreservedPreserved
es2017PreservedPreserved
es2015DownleveledDownleveled
esnextPreservedPreserved

Limitations

LimitationDetail
ES5 not supportedMinimum is es2015
API polyfills not includedtarget affects syntax only
Order-dependence bugLast 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

PitfallWhy It HappensFix
Invalid target errores5 or an unknown stringUse es2015 or a browser target
Older browser fails to parseTarget not set; default is esnextSet the target to the oldest supported environment
Output is larger than expectedDownleveling to an unnecessarily low targetRaise the target to the actual minimum
Promise is not definedTarget affects syntax, not APIsAdd a polyfill
Syntax not downleveled despite a low targetDuplicate engine in the list; last one winsRemove 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

ItemValue
PurposeDownlevel syntax for older environments
Value typesECMAScript version or browser version
Multiple targetsConservative (most restrictive)
esnextNo downleveling
ES5 supportNone (minimum es2015)
API polyfillsNot provided by target
Duplicate engine bugLast version wins
Defaultesnext

Key takeaways:

  • target controls syntax downleveling, not API availability. It rewrites ?., ??, async/await, and similar constructs for older environments. It does not provide Promise, fetch, or Array.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 .
  • esnext disables 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 set target will 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 chrome87 encodes 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.

Was this helpful?


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!