esbuild is surprisingly opinionated about what JavaScript features it allows by default, and how it bundles them for browser consumption.
Let’s see what happens when we try to build a simple modern JavaScript file for the browser without specifying a target.
// src/index.js
const greet = (name) => {
console.log(`Hello, ${name}!`);
};
greet("World");
And our esbuild command:
esbuild src/index.js --bundle --outfile=dist/bundle.js
Running this, we get dist/bundle.js containing something like:
// dist/bundle.js
var greet = (e)=>{
console.log(`Hello, ${e}!`);
};
greet("World");
This looks pretty modern, right? Arrow functions, template literals. But if you were to open this in an older browser, say, Internet Explorer 11, you’d get a runtime error because IE11 doesn’t understand arrow functions. The problem is that esbuild defaults to a target that’s too modern for broad compatibility.
The target option in esbuild tells it which JavaScript features and syntax it’s allowed to use. When you don’t specify it, esbuild picks a default that’s usually quite recent, assuming you’re targeting modern browsers. This is great for performance and code size if you know your users are on up-to-date browsers, but it breaks compatibility if you need to support older ones.
The solution is to explicitly tell esbuild which browsers you need to support. esbuild uses the same target values as tsconfig.json and babel.config.js.
Let’s say we need to support IE11 and modern Chrome. We can specify this with the --target flag:
esbuild src/index.js --bundle --outfile=dist/bundle.js --target=es2015,chrome58,firefox57,safari11
Now, our dist/bundle.js will be transformed to be compatible with those targets:
// dist/bundle.js
var greet = function(e) {
console.log("Hello, ".concat(e, "!"));
};
greet("World");
Notice how the arrow function (name) => { ... } has been transpiled into a traditional function(e) { ... }, and the template literal `Hello, ${name}!` has become a string concatenation "Hello, ".concat(e, "!"). This is the magic of transpilation driven by the target setting, ensuring your code runs everywhere you need it to.
You can specify multiple targets. esbuild will then generate code that’s compatible with the intersection of all specified targets. For example, if you target es2015 and chrome58, it will generate code compatible with es2015 and chrome58. If you just want to target a broad range of modern browsers, you can often use a single, higher-level target like esnext and let esbuild figure out the specifics.
The most common way to set the target for a project is by creating an esbuild.config.js file. This keeps your build configuration separate and organized.
// esbuild.config.js
require('esbuild').build({
entryPoints: ['src/index.js'],
bundle: true,
outfile: 'dist/bundle.js',
target: ['es2015', 'chrome58', 'firefox57', 'safari11'], // Or just 'es2015' for broader ES compatibility
}).catch(() => process.exit(1))
Then, you’d run node esbuild.config.js or add it to your package.json scripts: "build": "node esbuild.config.js".
The choice of target directly impacts the output code’s size and performance. A more modern target means esbuild can use newer, more efficient JavaScript features and less polyfill code, resulting in smaller bundles and faster execution in supported browsers. Conversely, targeting older environments requires more transpilation and potentially larger polyfills, leading to bigger files and slower performance on those older browsers.
The target option is not just about syntax; it also influences which built-in APIs esbuild assumes are available. For example, targeting es2015 implies that Promise and Array.prototype.find are available, whereas targeting an older environment might require esbuild to include polyfills for these.
One thing that often trips people up is the difference between target and platform. While target specifies the ECMAScript version and browser features, platform (browser, node, or neutral) tells esbuild about the environment where the code will run, affecting how it handles certain modules and global variables. For example, platform: "node" will allow require() statements and Node.js specific globals, while platform: "browser" will expect import and browser-specific globals like window. If you’re building for the web, you almost always want platform: "browser".
If you’re using a tool like Create React App or Vite, they often manage esbuild (or a similar bundler) for you and have sensible defaults for browser compatibility, usually based on package.json’s browserslist field. However, understanding esbuild’s target is crucial when you’re configuring builds manually or need fine-grained control.
The next hurdle is understanding how esbuild handles different module formats and the implications for your project’s architecture.