fast-esbuild-wasm
All of esbuild, ported from Go to JavaScript. There is no Go binary and no WebAssembly to download, and it returns the same code, source maps, messages and errors as esbuild-wasm.
"esbuild-wasm": "npm:@r1ck404/fast-esbuild-wasm@0.28.5" in your package.json is the whole migration.
- 3-6xtransform, in Chromium
- 2.4-3.2xbuild() with a plugin file system
- 14 MB to 2.2 MBbrowser bundle, minified
- 126,258JS and TS outputs compared byte for byte
esbuild-wasm output
fast-esbuild-wasm output
Why a Go binary is heavy in a browser
esbuild is a Go program. esbuild-wasm is that program compiled to WebAssembly, and a page that wants to use it has to bring all of it: esbuild.wasm, which is 14 MB with the JavaScript around it and 2.7 MB after brotli, a copy of Go's wasm_exec glue that lets the Go runtime call into the browser, and a worker to run it in. initialize() fetches and compiles the wasm and starts the Go program. From then on, every transform() turns its options into flags, encodes the input to UTF-8, sends both to Go as a packet, and decodes the reply.
That is a fair price for a build tool that starts once. It hurts when a tool runs esbuild in a browser tab, such as a code playground or an in-browser runtime like Nodepod, over many small files. A cold initialize() plus the first transform() takes 152 ms with esbuild-wasm and 56 ms with fast-esbuild-wasm (Chromium, medians of fresh processes). Go's own runtime and garbage collector also live inside the wasm memory. Earlier versions of this package still shipped the Go binary and tried to tune it: a garbage collector setting made big inputs 1.3x faster but grew memory that never shrinks, and a wasm optimizer made it smaller but not faster. The attempts are written down.
Why JavaScript can win here
The port is not faster because JavaScript is a faster language than Go. It is faster in a browser because it removes what surrounds the work: the 14 MB download and compile, the start of Go's runtime, and the encoding of every input into bytes for another runtime to read. The compiler, parser and printer become ordinary JavaScript that the browser's engine compiles and optimizes like any other code, with strings that stay JavaScript strings.
The gain has a shape, and the demos on this page show it. It is largest on small inputs, where the fixed costs dominate: 4 to 6x for inputs up to about 50 KB, and 10x for a 1.6 KB Vite renderChunk. It shrinks to about 3x on megabyte inputs, where the time is the compiler's own work. And it costs more for input nested so deeply that the port needs its slower mode.
What runs where
esbuild.transform(code, { loader: "tsx" }), esbuild.build({ plugins }), exactly as before.transform request with the response packet the service would send. Runs in the page for inputs up to 64 KB, in the engine's own worker above that.cmd/esbuild/service.go. It reads the packets the glue writes and answers in the same format. Builds, contexts and formatMessages go here, in the page.esbuild.wasm, Go's runtime, the wasm_exec glue and the worker that ran them.esbuild-wasm's own codenew machineryno longer shipped
How it stays identical
Two decisions do most of the work. The first is to keep esbuild-wasm's own JavaScript as it is. Option validation, flag generation, error objects and the plugin protocol are esbuild's code, so a wrong option throws the same error and a plugin sees the same arguments. The patches are few: initialize() starts the in-page service instead of loading the Go binary, transform() asks the shortcut first, and in Node the default working directory is the process's. Every patch has to match the upstream text exactly once, or the package does not build.
The second is to keep the protocol. What esbuild's JavaScript writes to Go's stdin goes to the port of the service, in the same packets, and the answers come back the same way. That is why initialize() still checks wasmURL, wasmModule and worker and raises the same errors, and then ignores the binary. Any URL is accepted, because nothing is fetched.
A translation, not a rewrite
The engine follows a written rule set, CONVENTIONS.md: translate function by function, do not improve or simplify esbuild's logic, do not reorder conditions, do not merge functions. Names are derived mechanically so files ported in parallel agree: Go's ASCIIOnly becomes asciiOnly, methods on enum types become functions such as opCodeIsPrefix(op), and constants keep their exact Go names, BinOpAdd stays BinOpAdd.
Most of the care goes into where Go and JavaScript disagree. Go copies a struct on assignment and JavaScript shares it, so the port clones wherever Go copies and then mutates. Go works on UTF-8 bytes and JavaScript on UTF-16 strings, so every location is a UTF-16 offset and message columns are converted back to Go's byte columns. Bytes that are not valid UTF-8 travel as lone surrogates, so they survive to the output unchanged. And the port follows esbuild-wasm, which is Go on js/wasm, not native esbuild. Go converts NaN to an integer as 0 on wasm and as the minimum integer on amd64, so 1 >>> NaN folds under minifySyntax in esbuild-wasm and in the port, but not in native esbuild.
esbuild-wasm output
fast-esbuild-wasm output
onResolve and onLoad from an object in memory, and both builds get the same plugin code. Every build also asks for a metafile, and the comparison covers each output file's path, text and hash, the metafile, and the messages. The plugin callbacks are counted: both sides should make the same calls.Where the speed comes from
Being a port of the same algorithm sets the ceiling. What sets the floor is how the port starts, where it runs, and what it avoids doing twice.
- It is compiled while the script loads. The engine is wrapped in a parenthesized function, which V8 compiles right away. A function without them would be pre-parsed at load and parsed again on the first call, about 20 ms according to the build's comment. In the builds that are not minified the engine is also compacted, keeping its function and class names for stack traces.
- esbuild's runtime helpers are not parsed at run time. Helpers such as
__toCommonJSare code that esbuild parses on first use. The build bakes in a snapshot of their syntax tree, and the tests check it against a fresh parse. It covers targetesnextwithoutminifySyntaxorminifyIdentifiers. Other options parse the runtime on their first use. - The engine warms up before you ask. The engine's worker starts in a task right after
initialize()and runs a few representative transforms.initialize()itself resolves in under 1 ms. - Small inputs stay in the page. A round trip to a worker costs about 50 µs plus 2 µs per KB in Chromium: 40% of a 1.6 KB transform and 7% of an 80 KB one. So inputs up to 64 KB run in the page, once its copy of the engine has warmed up in idle time. A 64 KB transform keeps the page busy for about 2.5 ms at most. Larger inputs go to the worker, and the page stays responsive.
- Repeated work is cached, and the caches are tested. The printed code of the runtime helpers, several linker steps that depend only on the runtime, and the reserved names its scope contributes are reused when nothing they depend on has changed. A test hook re-computes every cache hit and fails on a mismatch.
- Small things add up. The printer writes into a growable UTF-8 buffer and decodes it once per part. Functions that return several values are read by index instead of by array destructuring. Binary-expression visitors are pooled, and a symbol lookup reuses its result object.
The next panel races both builds on generated TypeScript from 1 KB to 1 MB, so you can see how the speedup moves with size, and times fast-esbuild-wasm on deeply nested input.
Each row transforms the same input with both builds. The sizes run generated TypeScript from 1 KB to 1 MB, and the depths run arrays nested that many levels. Both take a few seconds.
Deep input is slower, and that is the price of not crashing
Go grows its stack as a program needs it. A JavaScript thread's stack is small and fixed. The engine runs in the calling thread, so input nested more deeply than the stack allows, a few hundred to a few thousand levels depending on the construct, would overflow it. When that happens the engine runs the step again in deep mode: at build time every recursive function of the engine gets a generator copy that runs on an explicit stack, so depth is limited by memory instead.
Deep mode is 2 to 20 times slower than the normal code. It only runs for input that needs it, and the nesting rows in the panel above show it as a rise in the time per level. In exchange every construct the tests measure, from arrays and calls to JSX, TypeScript types and CSS rules, goes at least twice as deep as esbuild-wasm does in the calling thread (16,384 or more nested arrays against 6,016, for example, in the calling thread). Past the port's own limit, about 130,000 nested arrays, you get esbuild's panic message. esbuild-wasm fails with Maximum call stack size exceeded and its Go program exits.
What is different
initialize() never loads a binary. A wasmURL that cannot be downloaded, or a wasmModule that is not esbuild's, does not make it reject. It still validates the options and raises esbuild-wasm's errors for bad ones. The package holds one WebAssembly module of 549 bytes, which hashes output files of 256 bytes and more; without WebAssembly the same hash is computed in JavaScript.
In worker mode, esbuild-wasm runs everything in its worker. Here transforms run in the engine's worker or, when small, in the page, and builds run in the page: they are asynchronous, but their work happens on the page's thread. A large build can hold the page for its whole duration, in exchange for taking 2.4 to 3.2x less time.
A few corners differ on purpose. A build input that ends in a truncated UTF-8 sequence, with a source map requested, makes esbuild hang in Go's QuoteForJSON; the port reports a panic instead. Panic messages show the JavaScript stack where Go shows its goroutine trace. A genuine lone surrogate between U+DC80 and U+DCFF in a string value is printed as the raw byte, because that is how the port carries invalid UTF-8. The hidden --timing flag lists chunks in chunk order. Where worker_threads is missing, buildSync() runs the package's own bin/esbuild as a child process.
On size, the browser bundle drops from 14.05 MB to 2.17 MB minified, and from 2.72 MB to 0.40 MB with brotli. The npm tarball shrinks less: 2.73 MB against 3.86 MB, and the unpacked package is 12.36 MB against 14.53 MB, because it ships four copies of the browser build (lib/ and esm/, plain and minified) and the Node build.
What has been checked
Every comparison is against esbuild-wasm 0.28.2, or against native esbuild 0.28.2 where the two agree. The last full run found 0 differences in: 126,258 JavaScript and TypeScript transform outputs over 3,944 files and 57 option sets (code, source map, warnings and errors); 94,144 CSS outputs; 852 plus 1,155 builds with a plugin file system and with the real one; 73,724 resolver results; and messages under 12 log settings. A separate check written independently, calling the public transform() API the way an application does, compared 8,075 outputs. The browser builds run in Chromium, Firefox and WebKit next to esbuild-wasm, and a test fails if any file of the published package is a Go binary or needs one.
That is a lot of agreement, and it is still a sample. If the two ever return different bytes for the same call, the port is wrong, not esbuild-wasm. The package counts the transforms its shortcut passed on to the service because the engine threw, either a Go panic that esbuild would report too or a bug in the port, and asks you to report those.
Recorded results
Measured on a Ryzen 7 7800X3D with Chromium 149 and Node 24.16, one call per row: esbuild-wasm's time, fast-esbuild-wasm's time, and the ratio. The implementations alternate in fresh processes and each row keeps the best of three rounds. There are three tables. The first is the browser build in Chromium in worker mode. The second is the calls Vite makes, also in Chromium. The third is Node, with native esbuild for reference.
Read the Node table with care. In Node, esbuild-wasm waits for the operating system's timer, 15.6 ms on Windows, on every call. That inflates the speedup on small inputs: the 24x on twenty small components comes from a 15.4 ms call that is close to one such wait. The browser tables are the fairer comparison. Native esbuild is a different animal, not something a browser can run, but it is a useful yardstick: it is still faster on three of the five Node rows, and fast-esbuild-wasm takes 9% to 42% longer than it there. On the other two, the small .tsx input and vite:define, fast-esbuild-wasm is ahead of native esbuild.
In the browser (headless Chromium, the browser build in its default worker mode, which is how esbuild-wasm runs in a page; the page timer resolves
| call | input | esbuild-wasm | fast-esbuild-wasm | speedup |
|---|---|---|---|---|
| ESM → CommonJS | 1.6 KB | 1.4 ms | 0.3 ms | 4.7x |
| ESM → CommonJS | 51 KB | 11.4 ms | 2.1 ms | 5.4x |
| ESM → CommonJS | 17 files one after another | 56.5 ms | 10.0 ms | 5.7x |
| ESM → CommonJS | rollup (948 KB) | 127.4 ms | 36.5 ms | 3.5x |
| ESM → CommonJS | three.module (1.2 MB) | 65.1 ms | 20.5 ms | 3.2x |
| TypeScript → ESM | 31 KB | 4.5 ms | 1.0 ms | 4.5x |
| TypeScript → ESM | 157 KB | 19.7 ms | 5.8 ms | 3.4x |
| TS + source map (Vite-style) | 157 KB | 27.0 ms | 7.6 ms | 3.6x |
| TSX + source map | 20 small components | 2.5 ms | 0.5 ms | 5.0x |
build(), plugin file system | zod | 121.1 ms | 37.5 ms | 3.2x |
build(), minify + source map | zod | 159.0 ms | 51.2 ms | 3.1x |
build() | lodash-es (640 files) | 400.8 ms | 126.3 ms | 3.2x |
build() | three (1.2 MB) | 227.3 ms | 86.8 ms | 2.6x |
build() | react-dom client (1 MB CommonJS) | 94.7 ms | 39.0 ms | 2.4x |
initialize() (script load included) | 37.0 ms | 35.0 ms | 1.1x | |
initialize() + first transform() | 152.4 ms | 55.9 ms | 2.7x |
Vite's own calls, Chromium, worker mode (these used to need esbuild's Go code, because of supported, object defines, minify and build targets):
| Vite call | input | esbuild-wasm | fast-esbuild-wasm | speedup |
|---|---|---|---|---|
renderChunk (Vite 7 build targets, minify) | 1.2 MB | 110.7 ms | 29.9 ms | 3.7x |
renderChunk | 51 KB | 15.7 ms | 3.86 ms | 4.1x |
renderChunk | 1.6 KB | 1.35 ms | 0.13 ms | 10x |
vite:esbuild .ts (supported, source map) | 157 KB | 28.6 ms | 9.8 ms | 2.9x |
vite:esbuild .ts | 9 KB | 2.80 ms | 0.48 ms | 5.8x |
vite:esbuild .tsx | 20 components | 2.74 ms | 0.47 ms | 5.8x |
vite:define (process.env: "{}") | 51 KB | 14.0 ms | 2.97 ms | 4.7x |
In Node (@r1ck404/fast-esbuild-wasm/node.mjs, in-thread), with native esbuild for reference:
| Vite call | input | esbuild-wasm | fast-esbuild-wasm | speedup | native esbuild |
|---|---|---|---|---|---|
renderChunk | 1.2 MB | 119 ms | 38.7 ms | 3.1x | 27.3 ms |
renderChunk | 51 KB | 25 ms | 4.5 ms | 5.6x | 3.6 ms |
vite:esbuild .ts | 157 KB | 33 ms | 8.5 ms | 3.9x | 7.8 ms |
vite:esbuild .tsx | 20 components | 15.4 ms | 0.65 ms | 24x | 1.3 ms |
vite:define | 51 KB | 19 ms | 3.6 ms | 5.3x | 4.0 ms |