esbuild: Bundle JavaScript in Milliseconds, Not Seconds
You change one line in a small project, save, and wait. The bundler thinks, the terminal scrolls, and a few seconds pass before the page refreshes. Do that forty times in an afternoon and you have lost several minutes to staring at a spinner, plus the focus you lose every time the flow breaks. Most bundlers are slow because they are written in JavaScript and parse everything with a JavaScript parser. esbuild is written in Go, parses in parallel, and treats bundling as a compile step rather than a scripting task. The question is whether that speed is worth changing tools for, and what you give up in return.
What I would check before using it
My first check is how much the build actually hurts. Two files bundle in milliseconds under any tool. The pain starts with hundreds of modules and a rebuild on every save. If a rebuild already takes seconds, esbuild's speed is the whole point. If builds are already under a second, keep what you have.
My second check is what the project needs besides bundling. esbuild handles JavaScript, TypeScript, JSX, CSS, and minification, which covers most apps. It does not type check TypeScript, so a type error sails through the build, and you need a separate tsc run. It has no dev server with hot module replacement, only watch mode and a small serve mode. If the project leans on a framework's dev server, a bigger tool still fits better.
My third check is how much configuration the team wants. esbuild is a command and a handful of flags, a relief after a long config file. The trade is that anything unusual needs a plugin, and the plugin ecosystem is smaller than the one around webpack or Vite. Count the custom steps in the current build first. If the list is short, esbuild covers it.
My fourth check is the output target. esbuild bundles for the browser and for Node, and this post uses the Node target, which suits command line tools and small servers.
My verdict, as judgement: use esbuild when the build is slow enough to notice and the project needs bundling, minifying, and TypeScript stripping rather than a framework's dev server. Skip it when you depend on hot module replacement or heavy plugins. The recommendation flips when the plugin list outgrows the source list, or when the team needs type checking inside the build.
A shop price calculator, bundled end to end
The example is a small shop price calculator in two files. The entry file, index.js, holds the order and prints the receipt. A helper module, pricing.js, computes line totals, a 10 percent discount on orders over $100, and 8 percent tax. Three tote bags at $18, three mugs at $14.50, and two notebook sets at $9.25 come to a subtotal of $116, so the discount kicks in, which makes the numbers worth checking by hand.
Here is pricing.js:
function lineTotal(price, qty) {
return price * qty;
}
function applyDiscount(subtotal) {
if (subtotal >= 100) return subtotal * 0.1;
return 0;
}
function addTax(amount) {
return amount * 0.08;
}
module.exports = { lineTotal, applyDiscount, addTax };
And here is index.js, which imports those three functions and runs the order:
const { lineTotal, applyDiscount, addTax } = require("./pricing");
const order = [
{ name: "Canvas tote bag", price: 18.0, qty: 3 },
{ name: "Ceramic mug", price: 14.5, qty: 3 },
{ name: "Notebook set", price: 9.25, qty: 2 },
];
let subtotal = 0;
for (const item of order) {
const total = lineTotal(item.price, item.qty);
subtotal += total;
console.log(`${item.qty} x ${item.name} = $${total.toFixed(2)}`);
}
const discount = applyDiscount(subtotal);
const afterDiscount = subtotal - discount;
const tax = addTax(afterDiscount);
const grandTotal = afterDiscount + tax;
console.log(`Subtotal: $${subtotal.toFixed(2)}`);
console.log(`Discount (10% over $100): -$${discount.toFixed(2)}`);
console.log(`Tax (8%): $${tax.toFixed(2)}`);
console.log(`Grand total: $${grandTotal.toFixed(2)}`);
The build command is short: bundle the entry file, target Node, and write one output file.
./node_modules/.bin/esbuild index.js --bundle --platform=node --outfile=bundle.cjs
This is the real build output:
bundle.cjs 1.5kb
Done in 4ms
Four milliseconds, and a single 1,496 byte file. Opening bundle.cjs shows both files inlined into one: the pricing helpers first, then the entry code, with a small helper at the top that wires the CommonJS modules together. Nothing else is in there, because the only dependency is the project's own helper file. Running the bundle with Node gives the real receipt:
3 x Canvas tote bag = $54.00
3 x Ceramic mug = $43.50
2 x Notebook set = $18.50
Subtotal: $116.00
Discount (10% over $100): -$11.60
Tax (8%): $8.35
Grand total: $112.75
The numbers check out by hand: $116 minus the $11.60 discount is $104.40, and 8 percent tax on that is $8.35, giving $112.75. Running the original index.js directly prints exactly the same receipt, which is the test that matters: the bundle behaves like the source.
The thing that went wrong came before the successful build. My first attempt had a typo in the import, require("./price") instead of require("./pricing"), and the build stopped at once:
[ERROR] Could not resolve "./price"
index.js:1:53:
1 | const { lineTotal, applyDiscount, addTax } = require("./price");
| ~~~~~~~~~
1 error
The build exited with code 1 and wrote no bundle file, which is the correct behaviour: a failed build should not leave a stale bundle behind for you to run by mistake. The fix was the missing "ing" on the file name, and the next build finished in 4ms. The lesson is small but worth keeping: esbuild reports the bad import with the file and line number, so a broken path is a ten second fix rather than a hunt through the output.
Common mistakes and how to fix them
First, forgetting the --bundle flag. Without it, esbuild still writes an output file, but the require("./pricing") line stays in place, so the bundle is not self contained. It runs fine while pricing.js sits next to it, then fails the moment you ship the bundle alone, with "Error: Cannot find module './pricing'". The fix is to always pass --bundle when you want one file, and to test the bundle in a folder of its own.
Second, forgetting --platform=node when bundling for Node. esbuild targets the browser by default, so a file that uses a Node built-in module stops the build with "Could not resolve 'fs'", plus a hint to use --platform=node. The fix is to name the platform in the command, and to keep browser and Node builds as separate commands.
Third, assuming esbuild type checks TypeScript. It strips types and bundles the code without checking them, so a wrong type in a .ts file builds cleanly and only fails later, in tests or at runtime. The fix is to run tsc --noEmit as a separate step in the same script that runs the build, so type errors still block a release.
My call on esbuild
esbuild does one job, bundling, and it does it fast enough that the build step stops being something you wait for. The calculator above went from two files to a single 1.5kb bundle in 4ms, and the bundle printed the same receipt as the source, which is all a bundler has to prove. Its limits are plain: no type checking, no hot module replacement, and a smaller plugin world than the big tools. For a small app, a command line tool, or a library build, that trade is easy to take. Try it on one project and time it against your current tool. If the difference is seconds per rebuild, keep it.
Further reading
- esbuild documentation, Getting Started, https://esbuild.github.io/getting-started/
- esbuild documentation, API reference, https://esbuild.github.io/api/
- esbuild documentation, JavaScript API, https://esbuild.github.io/api/js/

Comments
Post a Comment