# How to compile JavaScript into a single executable

> 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.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-10-07 | Topics: [JavaScript](https://flaviocopes.com/tags/js/) | Canonical: https://flaviocopes.com/javascript-single-executable/

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:

```bash
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`:

```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](https://flaviocopes.com/node-cli-args/). If you want to build a bigger CLI first, the free [Node.js course](https://flaviocopes.com/courses/nodejs/build-a-notes-cli/) builds a notes tool step by step.

Let's try it on the Markdown source of my Deno post:

```bash
node readtime.js deno.md
```

```text
deno.md: 1002 words, 6 min read
```

Pass `--wpm` to change the reading speed:

```bash
node readtime.js deno.md --wpm 250
```

```text
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:

```bash
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](https://flaviocopes.com/deno/) covers the basics.

Let's try it:

```bash
deno compile --allow-read --output readtime readtime.js
```

In a project with a `package.json` and a `node_modules` folder, this first try fails:

```text
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`:

```bash
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:

```bash
./readtime deno.md
```

```text
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:

```text
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`:

```bash
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-gnu` and `aarch64-unknown-linux-gnu` for Linux
- `x86_64-apple-darwin` and `aarch64-apple-darwin` for macOS
- `x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc` for 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`:

```bash
deno compile --no-check --include help.txt --output readtime readtime.js
```

Then read it relative to `import.meta.dirname`, which points inside the executable:

```js
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](https://docs.deno.com/runtime/reference/cli/compile/).

## Compile with Bun

In Bun, compiling is a flag of the bundler:

```bash
bun build --compile readtime.js --outfile readtime
```

```text
  [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:

```bash
./readtime deno.md
```

```text
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:

```bash
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:

```bash
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:

```js
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`:

```js
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](https://flaviocopes.com/bun/) shows how to write the server, and the free [Bun course](https://flaviocopes.com/courses/bun/compile-and-ship-the-application/) ends by compiling and shipping a small API.

The full list of options is in [Bun's executables docs](https://bun.com/docs/bundler/executables).

## 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:

```json
{
  "main": "readtime.js",
  "mainFormat": "module",
  "output": "readtime"
}
```

`mainFormat` tells Node our file is an ES module. Now build it:

```bash
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:

```bash
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:

```bash
./readtime deno.md
```

```text
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:

```bash
npm install -D esbuild
npx esbuild readtime.js --bundle --platform=node --outfile=dist/readtime.cjs
```

```text
  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:

```json
{
  "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:

```bash
node --build-sea sea-config.json
codesign --sign - readtime
./readtime deno.md
```

```text
deno.md: 1002 words, 6 min read
```

That's three commands to remember, so put them in a `build` script in `package.json`:

```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`:

```json
{
  "main": "dist/readtime.cjs",
  "output": "readtime",
  "disableExperimentalSEAWarning": true,
  "assets": {
    "help.txt": "help.txt"
  }
}
```

Then read them with `getAsset()` from the `node:sea` module:

```js
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](https://nodejs.org/en/download), 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:

```json
{
  "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:

```json
{
  "main": "dist/readtime.cjs",
  "output": "sea-prep.blob",
  "disableExperimentalSEAWarning": true
}
```

Then run these commands on a Mac:

```bash
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](https://nodejs.org/api/single-executable-applications.html).

## 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:

```bash
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](https://flaviocopes.com/macos-open-anyway/). 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.
