Skip to content
Codeloom
Node.js

Node.js Single Executable Applications (SEA)

Bundle your Node.js application into a single executable binary with no runtime dependency. Covers the SEA workflow and production tips.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What single executable applications are
  • How to create a SEA from a Node.js project
  • Bundling assets and handling dependencies
  • Cross-compilation and distribution strategies

Prerequisites

  • Basic Node.js knowledge
  • Node.js 20 or later installed
  • Familiarity with npm and the build process

Node.js Single Executable Applications (SEA) let you distribute your application as a standalone binary. The end user does not need Node.js installed. You ship one file, they run it. This is useful for CLI tools, internal utilities, and edge deployments where installing a runtime is impractical.

How it works

SEA works by injecting a JavaScript blob into a copy of the Node.js binary itself. When the modified binary runs, it executes your bundled code instead of starting the usual Node.js REPL. The result is a single file that contains both the runtime and your application.

The process has three steps:

  1. Create a preparation blob from your JavaScript source
  2. Copy the Node.js binary
  3. Inject the blob into the copied binary

Step-by-step: creating your first SEA

1. Write your application

// app.js
const args = process.argv.slice(2);
const command = args[0];

if (command === 'greet') {
  const name = args[1] || 'World';
  console.log(`Hello, ${name}!`);
} else if (command === 'version') {
  console.log('my-cli v1.0.0');
} else {
  console.log('Usage: my-cli <greet|version> [name]');
}

2. Create a SEA configuration file

{
  "main": "app.js",
  "output": "sea-prep.blob",
  "disableExperimentalSEAWarning": true,
  "useSnapshot": false,
  "useCodeCache": true
}

Save this as sea-config.json. The useCodeCache option pre-compiles your JavaScript to V8 bytecode, which speeds up startup.

3. Generate the preparation blob

node --experimental-sea-config sea-config.json

This produces sea-prep.blob, a binary file containing your compiled application.

4. Copy and inject

On macOS:

cp $(command -v node) my-cli
codesign --remove-signature my-cli
npx postject my-cli NODE_SEA_BLOB sea-prep.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
  --macho-segment-name NODE_SEA
codesign --sign - my-cli

On Linux:

cp $(command -v node) my-cli
npx postject my-cli NODE_SEA_BLOB sea-prep.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

On Windows:

copy node.exe my-cli.exe
npx postject my-cli.exe NODE_SEA_BLOB sea-prep.blob `
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

5. Run it

./my-cli greet Alice
# Hello, Alice!

./my-cli version
# my-cli v1.0.0

The binary is self-contained. Copy it to any machine with the same OS and architecture, and it runs without Node.js installed.

Bundling a real project

Most applications have dependencies. Since SEA takes a single JavaScript file as input, you need to bundle your project first. Use esbuild for this:

npm install --save-dev esbuild
{
  "scripts": {
    "bundle": "esbuild src/index.js --bundle --platform=node --outfile=dist/app.js --external:fsevents",
    "build-sea": "npm run bundle && node --experimental-sea-config sea-config.json"
  }
}

Update sea-config.json to point to the bundled output:

{
  "main": "dist/app.js",
  "output": "sea-prep.blob",
  "disableExperimentalSEAWarning": true,
  "useCodeCache": true
}

Handling native modules

Native addons (.node files) cannot be bundled into the blob. If your project uses native modules like better-sqlite3 or sharp, you need to distribute them alongside the binary or find pure JavaScript alternatives.

Mark native modules as external in esbuild:

esbuild src/index.js --bundle --platform=node \
  --outfile=dist/app.js \
  --external:better-sqlite3 \
  --external:sharp

Then ship the native module files alongside your binary.

Including assets

You can embed static assets (templates, config files, etc.) into the blob using the SEA assets feature:

{
  "main": "dist/app.js",
  "output": "sea-prep.blob",
  "disableExperimentalSEAWarning": true,
  "useCodeCache": true,
  "assets": {
    "template.html": "assets/template.html",
    "config.json": "assets/default-config.json"
  }
}

Access embedded assets in your code:

import { getAsset } from 'node:sea';

const template = getAsset('template.html', 'utf-8');
const config = JSON.parse(getAsset('config.json', 'utf-8'));

This eliminates the need to distribute separate asset files alongside your binary.

V8 startup snapshot

For faster cold starts, you can use a V8 startup snapshot. This pre-initializes your application’s state and bakes it into the binary:

{
  "main": "dist/app.js",
  "output": "sea-prep.blob",
  "disableExperimentalSEAWarning": true,
  "useSnapshot": true
}

With snapshots enabled, the V8 engine restores the heap from the snapshot instead of parsing and executing your JavaScript from scratch. This can reduce startup time significantly for large applications.

Note: snapshot mode has restrictions. You cannot use top-level import(), and some APIs behave differently during snapshot creation.

Automating the build

Here is a complete build script:

// build-sea.js
import { execSync } from 'node:child_process';
import { cpSync, chmodSync } from 'node:fs';
import { platform } from 'node:os';

const APP_NAME = 'my-cli';
const NODE_PATH = process.execPath;

// Step 1: Bundle
console.log('Bundling...');
execSync('npx esbuild src/index.js --bundle --platform=node --outfile=dist/app.js', {
  stdio: 'inherit',
});

// Step 2: Generate blob
console.log('Generating SEA blob...');
execSync('node --experimental-sea-config sea-config.json', {
  stdio: 'inherit',
});

// Step 3: Copy node binary
const binaryName = platform() === 'win32' ? `${APP_NAME}.exe` : APP_NAME;
cpSync(NODE_PATH, binaryName);

// Step 4: Remove signature on macOS
if (platform() === 'darwin') {
  execSync(`codesign --remove-signature ${binaryName}`);
}

// Step 5: Inject blob
console.log('Injecting blob...');
const machoFlag = platform() === 'darwin' ? '--macho-segment-name NODE_SEA' : '';
execSync(
  `npx postject ${binaryName} NODE_SEA_BLOB sea-prep.blob ` +
  `--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 ${machoFlag}`,
  { stdio: 'inherit' }
);

// Step 6: Re-sign on macOS
if (platform() === 'darwin') {
  execSync(`codesign --sign - ${binaryName}`);
}

// Step 7: Make executable
if (platform() !== 'win32') {
  chmodSync(binaryName, 0o755);
}

console.log(`Built: ./${binaryName}`);

Size considerations

The resulting binary includes the entire Node.js runtime, so expect a minimum size of about 80-100 MB. To reduce this:

  • Use useCodeCache: true to skip parsing at runtime (slightly larger binary, faster startup)
  • Strip debug symbols: strip my-cli on Linux
  • Compress with UPX: upx --best my-cli (reduces size by 50-70% but slightly slower startup)
  • Consider alternatives like Bun or Deno if binary size is critical

When to use SEA

Good use cases:

  • CLI tools: Distribute to users who may not have Node.js installed
  • Internal utilities: Ops tools that need to run on any server
  • Edge computing: Deploy to environments with limited package management
  • Kiosk applications: Single-file deployment to embedded systems

Not ideal for:

  • Web servers: Docker containers are a better deployment model
  • Frequently updated apps: Rebuilding the binary for every change is slow
  • Projects with many native addons: The bundling complexity outweighs the benefits

Key takeaways

Node.js SEA turns your JavaScript application into a standalone binary that runs without a Node.js installation. Use esbuild to bundle your project into a single file, generate a SEA blob, and inject it into a copy of the Node.js binary. Embed assets directly using the assets configuration. The binary is portable across machines with the same OS and architecture. For CLI tools and utilities, SEA simplifies distribution dramatically.