How to compile JavaScript into a single executable
By Flavio Copes
Turn a JavaScript program into one executable file, like Go and Rust do, with deno compile, bun build --compile and Node.js single executable apps.
Yes, you can turn a JavaScript program into one executable file, the way you do with Go or Rust. Deno has deno compile, Bun has bun build --compile, and Node.js builds what it calls a single executable application with node --build-sea.
You send someone that file, they run it, and it works. They don’t need Node.js, Deno or Bun installed, and there’s no node_modules folder to copy around.
In this post we’ll take a small command-line tool and compile it with all three. Same source file, three executables, from 62 MB to 146 MB.
How is this different from Go or Rust?
When you run cargo build, the Rust compiler turns your code into machine code. The tool we’re about to build is a 446 KB file when written in Rust.
The JavaScript tools don’t produce machine code. They take a copy of the runtime, the same program you run when you type node or bun, and put your JavaScript inside it. When you start the file, the runtime finds the embedded code and runs it like any other script.
So “compile” is a bit of a stretch here, and it’s why the files are big. A 23-line program becomes a 60 to 150 MB executable, because the whole JavaScript engine travels with it.
It’s still very useful. You can give a CLI to people who have never heard of npm, or copy one file to a server instead of installing a runtime and running npm install there.
The program we’ll compile
We’ll build readtime, a small tool that counts the words in a Markdown file and tells you how long it takes to read.
It uses two Node.js built-in modules and one npm package, picocolors, to color the output. That npm package is the interesting part, because each tool handles dependencies in its own way.
Create a folder and install the package:
mkdir readtime
cd readtime
npm init -y
npm pkg set type=module
npm install picocolors
npm pkg set type=module tells Node.js we write ES modules, so we can use import.
Now create readtime.js:
import { readFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import pc from 'picocolors'
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
wpm: { type: 'string', default: '200' },
},
})
const file = positionals[0]
if (!file) {
console.error('Usage: readtime <file.md> [--wpm 200]')
process.exit(1)
}
const text = readFileSync(file, 'utf8')
const words = text.split(/\s+/).filter(Boolean).length
const minutes = Math.ceil(words / Number(values.wpm))
console.log(`${pc.bold(file)}: ${words} words, ${pc.green(`${minutes} min read`)}`)
parseArgs from node:util reads the command line arguments, so we don’t need another dependency for that. I explain it in how to accept arguments from the command line in Node. If you want to build a bigger CLI first, the free Node.js course builds a notes tool step by step.
Let’s try it on the Markdown source of my Deno post:
node readtime.js deno.md
deno.md: 1002 words, 6 min read
Pass --wpm to change the reading speed:
node readtime.js deno.md --wpm 250
deno.md: 1002 words, 5 min read
The same file runs on Bun and Deno without changes, since both support Node’s built-in modules:
bun readtime.js deno.md
deno run --allow-read readtime.js deno.md
Deno needs --allow-read because it doesn’t let a program read files unless you say so. Keep that in mind, it comes back when we compile.
Compile with Deno
The command is deno compile. If you haven’t used Deno before, my introduction to Deno covers the basics.
Let’s try it:
deno compile --allow-read --output readtime readtime.js
In a project with a package.json and a node_modules folder, this first try fails:
error: Error: Could not find a matching package for 'npm:@types/node' in the node_modules directory.
deno compile type-checks your code before it builds anything. In a project managed by npm, Deno looks for the Node.js type definitions in node_modules, and they aren’t there.
Our file is plain JavaScript, so there’s nothing to check. Skip the check with --no-check:
deno compile --no-check --allow-read --output readtime readtime.js
If you write TypeScript and want the check, install the types with npm install -D @types/node instead. Be aware that Deno then embeds your whole node_modules folder in the executable, dev dependencies included. For this program that meant 2.5 MB of type definitions sitting next to 600 bytes of code.
Now run the file we just built:
./readtime deno.md
deno.md: 1002 words, 6 min read
Permissions are baked in
Notice that we passed --allow-read to deno compile, not when running the file. The permissions you give at compile time are stored in the executable, and whoever runs it can’t add more.
If you forget them, the program fails when it tries to read the file:
error: Uncaught (in promise) NotCapable: Requires read access to "deno.md", specify the required permissions during compilation using `deno compile --allow-read`
So you decide once what your tool is allowed to do, and you can list it in the README.
Build for other platforms
An executable only runs on the operating system and CPU it was built for. Mine runs on a Mac with Apple Silicon. To build for another platform, from the same machine, add --target:
deno compile --no-check --allow-read --target x86_64-unknown-linux-gnu --output readtime-linux readtime.js
deno compile --no-check --allow-read --target x86_64-pc-windows-msvc --output readtime readtime.js
The second command produces readtime.exe. These are the targets Deno supports:
x86_64-unknown-linux-gnuandaarch64-unknown-linux-gnufor Linuxx86_64-apple-darwinandaarch64-apple-darwinfor macOSx86_64-pc-windows-msvcandaarch64-pc-windows-msvcfor Windows
The first time you build for a target, Deno downloads a slimmed-down runtime for that platform, called denort, and caches it for the next builds.
Embed files
Deno embeds the code it finds by following your imports. If your program reads a file at runtime, add it with --include:
deno compile --no-check --include help.txt --output readtime readtime.js
Then read it relative to import.meta.dirname, which points inside the executable:
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const help = readFileSync(join(import.meta.dirname, 'help.txt'), 'utf8')
Reading an embedded file doesn’t need --allow-read. Without --include, the program fails with a NotFound: path not found error the first time it reaches that line.
Two more flags are worth knowing, and both are experimental. --bundle runs your code through Deno’s bundler first, so only the code you use ends up in the executable instead of your whole node_modules folder. --engine quickjs swaps V8 for QuickJS, a much smaller JavaScript engine, and the file drops from 68 MB to 37 MB. For readtime it also started slower (75 ms instead of 20 ms), so measure your own program before switching.
All the options are in the deno compile docs.
Compile with Bun
In Bun, compiling is a flag of the bundler:
bun build --compile readtime.js --outfile readtime
[3ms] bundle 2 modules
[81ms] compile readtime
That’s it. Bun followed the import, bundled picocolors with our code, and put the result inside a copy of the Bun runtime:
./readtime deno.md
deno.md: 1002 words, 6 min read
There’s no type check and no permission system, so there’s nothing else to set up.
Build for other platforms
Same idea as Deno, with Bun’s own target names:
bun build --compile --target=bun-linux-x64 readtime.js --outfile readtime-linux
bun build --compile --target=bun-windows-x64 readtime.js --outfile readtime
The targets are bun-linux-x64, bun-linux-arm64, bun-darwin-x64, bun-darwin-arm64, bun-windows-x64 and bun-windows-arm64.
Bun also has bun-linux-x64-musl and bun-linux-arm64-musl for Alpine Linux, which you’ll find in a lot of Docker images. Deno has no musl target, and Node’s single executable applications aren’t tested on Alpine.
Like Deno, Bun downloads the runtime for each target once and caches it.
Production flags
For something you ship to other people, Bun recommends a few more flags:
bun build --compile --minify --sourcemap --bytecode readtime.js --outfile readtime
--minify makes the code smaller. --sourcemap embeds a source map, so stack traces point at your original lines. --bytecode does the parsing at build time, so the program starts faster. For readtime startup went from 6.8 ms to 5.7 ms, which nobody notices on a tool this small, but it adds up on a big CLI.
Embed files
Bun embeds the files you import. A .txt file comes in as a string:
import help from './help.txt'
console.log(help)
For any other file, add with { type: 'file' }. You get back a path that works with Bun.file() and with node:fs:
import { readFileSync } from 'node:fs'
import logo from './logo.png' with { type: 'file' }
const bytes = readFileSync(logo)
Bun can also compile a full web app into one file: import an HTML file in your server code and it bundles the frontend into the same executable. My Bun guide shows how to write the server, and the free Bun course ends by compiling and shipping a small API.
The full list of options is in Bun’s executables docs.
Compile with Node.js
Node.js calls this feature single executable applications, or SEA. It takes more steps than Deno and Bun, for one reason: Node doesn’t bundle your code, so you have to do that first.
Let’s see what happens if we skip that. Create a sea-config.json file:
{
"main": "readtime.js",
"mainFormat": "module",
"output": "readtime"
}
mainFormat tells Node our file is an ES module. Now build it:
node --build-sea sea-config.json
--build-sea needs Node.js 25.5 or later. I’ll show the older way for Node 24 and 22 at the end of this section.
On a Mac there’s one more step. Node writes your code into a copy of its own binary, and that breaks the binary’s signature. macOS on Apple Silicon kills a program with a broken signature, so if you run it now it exits right away with code 137. Sign it again with an ad-hoc signature:
codesign --sign - readtime
Deno and Bun do this for you, which is why we didn’t need it before. On Linux there’s nothing to sign, and on Windows signing is optional.
Now run it:
./readtime deno.md
Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: picocolors
Inside a single executable application, import and require() can only load Node’s built-in modules. Our code imports picocolors from node_modules, and that folder isn’t inside the executable.
The fix is to bundle everything into one JavaScript file first. esbuild does it in one command:
npm install -D esbuild
npx esbuild readtime.js --bundle --platform=node --outfile=dist/readtime.cjs
dist/readtime.cjs 5.2kb
With --platform=node, esbuild leaves the node: imports alone and outputs CommonJS, the format Node expects for the embedded script by default. Any bundler works here, esbuild is the quickest to set up.
Point sea-config.json at the bundle:
{
"main": "dist/readtime.cjs",
"output": "readtime",
"disableExperimentalSEAWarning": true
}
disableExperimentalSEAWarning hides the warning Node otherwise prints on every run, saying that single executable applications are an experimental feature. Your users don’t need to see that.
Build, sign and run:
node --build-sea sea-config.json
codesign --sign - readtime
./readtime deno.md
deno.md: 1002 words, 6 min read
That’s three commands to remember, so put them in a build script in package.json:
"scripts": {
"build": "esbuild readtime.js --bundle --platform=node --outfile=dist/readtime.cjs && node --build-sea sea-config.json && codesign --sign - readtime"
}
Now npm run build does everything. On Linux, drop the codesign part.
Embed files
Node has its own API for embedded files. List them under assets in sea-config.json:
{
"main": "dist/readtime.cjs",
"output": "readtime",
"disableExperimentalSEAWarning": true,
"assets": {
"help.txt": "help.txt"
}
}
Then read them with getAsset() from the node:sea module:
import { getAsset, isSea } from 'node:sea'
const help = isSea() ? getAsset('help.txt', 'utf8') : 'Run the compiled readtime to see the help'
getAsset() throws when your code isn’t running inside an executable, for example when you run node readtime.js while developing, so check isSea() first.
Recent Node versions are also adding a useVfs option that lets you read embedded files with the regular node:fs functions, but it’s still in early development.
Build for other platforms
Node has no --target flag. Instead, you download the Node.js build for the platform you want from nodejs.org, take its node binary, and point the executable field at it. Here I copied the node binary from the Linux x64 download into a linux folder:
{
"main": "dist/readtime.cjs",
"output": "readtime-linux",
"executable": "linux/node",
"disableExperimentalSEAWarning": true
}
That binary must be the same Node version as the one running --build-sea. Leave useCodeCache and useSnapshot off in this case too, because the cache they create only works on the platform that created it.
On Node.js 24 and older
Before Node.js 25.5 there’s no --build-sea, and you get node: bad option: --build-sea. The older way has more steps. Node writes a preparation blob, and a separate tool called postject injects it into a copy of the node binary.
Set output in sea-config.json to the blob file:
{
"main": "dist/readtime.cjs",
"output": "sea-prep.blob",
"disableExperimentalSEAWarning": true
}
Then run these commands on a Mac:
node --experimental-sea-config sea-config.json
cp $(command -v node) readtime
codesign --remove-signature readtime
npx postject readtime NODE_SEA_BLOB sea-prep.blob \
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
--macho-segment-name NODE_SEA
codesign --sign - readtime
On Linux, skip both codesign commands and the --macho-segment-name option. These older versions only accept a CommonJS entry point, which is fine because esbuild already gave us one.
Everything else is in the Node.js single executable applications docs.
Size and speed compared
Here’s what the three tools produced for readtime, next to the same tool written in Rust. The numbers are from October 2026, on an Apple Silicon Mac with the current release of each tool. Startup is the median of 40 runs of readtime deno.md.
| File size | Startup | |
|---|---|---|
| Rust | 446 KB | 1.3 ms |
| Bun | 62 MB | 7 ms |
| Deno | 68 MB | 20 ms |
| Node.js | 146 MB | 24 ms |
The Linux x64 builds came out bigger than the macOS ones: 81 MB for Bun, 105 MB for Deno and 150 MB for Node.
Bun made the smallest file of the three and started the fastest. Most of that comes from the runtime: the Bun binary is smaller than the Node binary to begin with.
Node made the biggest file, because the node binary is about 147 MB before your code goes in. Running node readtime.js directly also took 24 ms, so the executable starts as fast as plain Node, no faster. Turning on useCodeCache made no difference for a program this small.
Rust is in another league, with a file more than 100 times smaller that starts in about a millisecond. If size or startup time really matter for your tool, JavaScript is the wrong language for it, and at that point I’d write it in Rust.
Things to know before you ship
One file per platform
A macOS executable doesn’t run on Linux, and an x64 Linux executable doesn’t run on an ARM server. If you publish a CLI, build one file for each platform you support and let people download the right one.
Your code isn’t hidden
Compiling doesn’t hide or encrypt your JavaScript. It sits inside the executable as text, and anyone can read it:
strings readtime | grep "min read"
With all three executables this prints our console.log() line, and Bun’s --bytecode build doesn’t hide the source either. Never put API keys or other secrets in the code. Read them from environment variables when the program runs.
Signing on macOS and Windows
The ad-hoc signature from codesign --sign - is enough to run the file on your own Mac. If people download it with a browser, macOS blocks it until they approve it by hand. To avoid that, sign it with an Apple Developer ID certificate and notarize it, the same two steps I describe for Mac apps in how to avoid the “Open Anyway” message. Files downloaded with curl skip that check, which is one reason so many CLIs install with a curl command.
On Windows, SmartScreen warns people before they run an unsigned .exe they downloaded.
Code you load dynamically
All three tools find your code by following import statements. A dynamic import() with a computed path, a worker file or a file you read at runtime is invisible to them, so you add it yourself: --include in Deno, an extra entry point or a file import in Bun, assets in Node.
Native addons, the .node files some npm packages ship, are the hardest case. Bun can embed them. Deno has a --self-extracting mode that writes the embedded files to disk on the first run. With Node, you write the addon to a temporary file yourself and load it from there.
Bun and Deno use their own runtime
Our readtime.js is plain Node.js code, and Bun and Deno compiled it without complaining. You don’t have to move your project to Bun or Deno to use them as a compiler.
But the executable runs on Bun or Deno, not on Node. Test the compiled file before you ship it, especially if you use less common Node APIs.
What about pkg and nexe?
Before Node had single executable applications, most people used Vercel’s pkg. It’s archived now. The last release was 5.8.1, and its README points to Node’s single executable applications. If an old project depends on it, the community fork @yao-pkg/pkg is still maintained. nexe is the other tool you’ll find in old tutorials.
For a new project, use one of the three built-in tools.
Which one should you use?
My advice is to start with Bun. It’s one command, it bundles your npm packages, it builds for Linux (Alpine included), macOS and Windows from one machine, and it gave us the smallest and fastest executable of the three.
Use deno compile if your project already runs on Deno, or if you like the idea of locking permissions into the binary.
Use Node’s single executable applications when your program needs Node itself, because of native modules, Node-specific behavior or a team that doesn’t want a second runtime. It takes a bundler and a few more commands, and --build-sea made it a lot simpler than it used to be.
Want me to talk about your product? You can sponsor this site.
Related posts about js: