Single Executable Applications
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences
of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were
checked against the official documentation on 2026-10-04, and the examples were run on Node.js 26 (macOS, arm64) and, where the text says so, on 24 and 22.
A single executable application (SEA) is a copy of the node binary with your program, and optionally its data files, injected into it, so that you can
hand one file to a machine that has no Node.js installed. The whole feature has Stability 1.1 (Active development): it works and is tested on CI,
but its configuration fields, its error messages and the way the blob is injected may still change between releases, so pin the Node.js
version you build with and re-test on every upgrade. In the Bookshelf scenario that grows across this section (see
Introduction & Architecture) the natural candidate is the command line tool that lists and
searches books, delivered to colleagues as a single download. A SEA is not a compiler and not an installer: it runs your JavaScript on a full V8 and
libuv runtime, so the file is as large as the node binary (about 145 MB for the builds shown here) plus your code and assets.
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
Single executable applications (the feature as a whole) |
1.1 Active development |
Introduced in v19.7.0 and v18.16.0. Supported on 22, 24 and 26 with different workflows (see the next rows). |
|
1.1 Active development |
Added in v25.5.0. Observed: it is available on Node.js 26 and is rejected as a bad option on 24.21.0 and 22.23.3, and the v24.x and v22.x documentation does not list it. |
|
1.1 Active development |
The older, two-step workflow. It still works on 26, 24 and 22, and the current documentation keeps it for verification and for external injection tools. |
|
1.1 Active development |
|
|
1.1 ( |
Documented on the Node.js 26 page. |
|
1.1 Active development |
Added in v21.7.0 and v20.12.0. |
|
1.1 Active development |
Added in v24.8.0 and v22.20.0. |
|
1 Experimental |
Module added in v26.4.0 and only available with |
|
Stable snapshot building, documented in the |
Needed when |
How a SEA works
Node.js does not compile your program to machine code. The build step creates a preparation blob that contains the main script (and optionally assets, a
V8 code cache or a heap snapshot) and embeds it in a copy of a node executable, in a format-specific place: a resource named NODE_SEA_BLOB in a
Windows PE file, a section of the same name in the NODE_SEA segment of a macOS Mach-O file, and a note in a Linux ELF file. A sentinel string (the NODE_SEA_FUSE_ string
quoted in the official page) inside the binary is flipped from 0 to 1 to tell the runtime that something was injected. When the process starts it
looks at that fuse: if it is set, the embedded script runs instead of the normal command line handling; otherwise Node.js behaves as usual. The
documentation labels this process as subject to change. The ELF, Mach-O and PE details are therefore interesting, but should not be coded against.
(plus assets)"] --> cfg["sea-config.json"] cfg --> build["node --build-sea
sea-config.json"] node["node binary
(same version)"] --> build build --> blob["preparation blob
(script, assets, cache or snapshot)"] blob --> inject["injection into a copy of node
(done internally)"] inject --> exe["executable"] exe --> sign["sign
(macOS required, Windows optional)"] sign --> run["run on a machine
without Node.js"]
Building with --build-sea
Since Node.js 25.5.0 one command does everything. node --build-sea sea-config.json reads the JSON configuration (paths are resolved relative to the working
directory), generates the blob and writes the final executable, so no external injection tool is needed. The node binary used to produce the
blob must be the same version as the one that receives it, which is automatically true when you let --build-sea copy the running binary.
echo 'console.log(`Hello, ${process.argv[2]}!`);' > hello.js
echo '{ "main": "hello.js", "output": "hello" }' > sea-config.json
node --build-sea sea-config.json
codesign --sign - hello
./hello world
Output (the ExperimentalWarning goes to standard error and can be silenced with disableExperimentalSEAWarning):
Generated single executable /home/me/.nvm/versions/node/v26.10.0/bin/node + sea-config.json -> hello
Hello, world!
(node:83024) ExperimentalWarning: Single executable application is an experimental feature and might change at any time
(Use `hello --trace-warnings ...` to show where the warning was created)
The main script is an ordinary script that has been bundled into one file. Inside it, require() and import can only load built-in modules, because
module loading does not read the file system (the section on limitations shows how to lift that). A bundler such as esbuild or Rollup, run before
--build-sea, turns an application with dependencies into the single file the build expects; the bundlers themselves are outside this page. The same
file, run with plain node, reports a different world: isSea() from node:sea is the supported way to tell the two apart.
The sea-config.json fields
| Field | Meaning | Default and constraints |
|---|---|---|
|
Path of the bundled script that is embedded. |
Required. Relative paths are resolved from the working directory. |
|
How the script is interpreted: |
|
|
The |
The current Node.js binary. When it is a different platform, |
|
Where the generated executable is written. |
Required. On Windows the |
|
Silences the experimental warning at start-up. |
|
|
Runs |
|
|
Compiles |
|
|
Exposes the assets as a read-only virtual file system. See The virtual file system for assets (useVfs). |
|
|
Node.js options applied automatically when the executable starts. |
Optional. They appear in |
|
Whether the user can add more options: |
|
|
Dictionary from an asset key to the file that is read at build time. |
Optional. See Bundling assets and the node:sea API. |
Bundling assets and the node:sea API
Anything that is not JavaScript (templates, JSON data, images, a license text) can be bundled through the assets dictionary. The files are read at build time,
stored in the blob and retrieved at run time by key with the node:sea module. The module has no effect outside a SEA, except for isSea(), which then returns
false.
| Function | Returns | Notes |
|---|---|---|
|
|
Whether the script runs inside a single executable application. |
|
|
Returns a copy. Throws when the key does not exist. |
|
|
|
|
|
Returns the bytes inside the executable without copying. Do not write to it: the section may not be writable or aligned and a write can crash the process. |
|
|
Throws when not running inside a SEA. Added in v24.8.0 and v22.20.0. |
The following Bookshelf command line tool is an ES module, so the configuration sets "mainFormat": "module". It bundles a catalogue and a banner as assets,
locks the runtime options with execArgv and refuses NODE_OPTIONS through "execArgvExtension": "none".
import { isSea, getAsset, getAssetKeys, getAssetAsBlob } from 'node:sea';
import { parseArgs } from 'node:util';
if (!isSea()) {
console.error('Not running inside a single executable application.');
process.exit(1);
}
const { values } = parseArgs({
options: { year: { type: 'string' }, keys: { type: 'boolean' } },
allowPositionals: true,
});
if (values.keys) {
console.log(getAssetKeys());
} else {
console.log(getAsset('banner.txt', 'utf8').trim());
const books = JSON.parse(getAsset('books.json', 'utf8'));
const blob = getAssetAsBlob('books.json', { type: 'application/json' });
console.log(`books.json is ${blob.size} bytes (${blob.type})`);
for (const book of books) {
if (!values.year || String(book.year) === values.year) {
console.log(`#${book.id} ${book.title} (${book.year})`);
}
}
console.log('execArgv:', process.execArgv, 'argv.length:', process.argv.length);
console.log('import.meta.filename === execPath:', import.meta.filename === process.execPath);
}
{
"main": "cli.mjs",
"mainFormat": "module",
"output": "bookshelf",
"disableExperimentalSEAWarning": true,
"execArgv": ["--max-old-space-size=256"],
"execArgvExtension": "none",
"assets": {
"books.json": "books.json",
"banner.txt": "banner.txt"
}
}
node --build-sea sea-config.json && codesign --sign - bookshelf
./bookshelf --year 2026
./bookshelf --keys
NODE_OPTIONS=--no-such-flag ./bookshelf --year 2025 # ignored: execArgvExtension is "none"
node cli.mjs # plain node: isSea() is false
Bookshelf CLI: list the books bundled into this executable.
books.json is 133 bytes (application/json)
#2 Bookshelf field guide (2026)
execArgv: [ '--max-old-space-size=256' ] argv.length: 4
import.meta.filename === execPath: true
[ 'books.json', 'banner.txt' ]
#1 Efficient Node.js sampler (2025)
execArgv: [ '--max-old-space-size=256' ] argv.length: 4
import.meta.filename === execPath: true
Not running inside a single executable application.
Two details are worth noting in the output. process.argv.length is 4 because user arguments start at index 2, exactly as with node script.js …, even though
the script is embedded. And in the injected script filename, import.meta.filename and process.execPath all point at the executable itself, never at a source
file, so code that locates sibling files with dirname needs the asset API (or the virtual file system below) instead.
With "execArgvExtension": "cli" the executable accepts --node-options="--flag1 --flag2" on its command line, which is the way to pass options that
NODE_OPTIONS does not allow; with the default "env" the NODE_OPTIONS environment variable extends the arguments. For a program that is distributed to others the
safest choice is "none", because it keeps the environment of the user from changing how the runtime starts.
Startup snapshots
With "useSnapshot": true the main script does not run when the executable starts. It runs once on the build machine, V8 serialises the resulting heap
into the blob, and each launch deserialises that heap and calls the function you registered with v8.startupSnapshot.setDeserializeMainFunction(). Work that
is expensive but deterministic (parsing a large catalogue, building lookup tables) is paid once. The usual constraints of startup snapshot scripts apply, and
mainFormat: "module" cannot be combined with useSnapshot.
const { setDeserializeMainFunction } = require('node:v8').startupSnapshot;
// Runs once, on the build machine: expensive start-up work is frozen into the snapshot.
const catalogue = Array.from({ length: 1000 }, (_, i) => ({ id: i, title: `Book ${i}` }));
console.log('building snapshot, catalogue size', catalogue.length);
// Runs on every launch, on top of the deserialized heap.
setDeserializeMainFunction(() => {
console.log('launched with', catalogue.length, 'books preloaded; arg:', process.argv[2]);
});
echo '{ "main": "snap.js", "output": "snap", "useSnapshot": true, "disableExperimentalSEAWarning": true }' > sea-config.json
node --build-sea sea-config.json && codesign --sign - snap && ./snap hi
building snapshot, catalogue size 1000
Generated single executable /home/me/.nvm/versions/node/v26.10.0/bin/node + sea-config.json -> snap
launched with 1000 books preloaded; arg: hi
The first line is printed by the build, not by the launch. Adding "mainFormat": "module" to that configuration fails the build with the message
"mainFormat": "module" is not supported when "useSnapshot" is true (observed text).
The V8 code cache
"useCodeCache": true makes the build compile main and embed the V8 code cache, so at launch the engine skips compilation and goes straight to execution. It
helps start-up time for large bundles and costs nothing in behaviour, with one documented exception: import() does not work when it is enabled.
echo '{ "main": "cc.js", "output": "cc", "useCodeCache": true, "disableExperimentalSEAWarning": true }' > sea-config.json
node --build-sea sea-config.json && codesign --sign - cc && ./cc
The documentation says import() does not work with the code cache. In a quick check on Node.js 26.10.0 a dynamic import('node:os') of a built-in
module did succeed from a CommonJS main, so treat the limitation as an upper bound (observed only for a built-in), and do not rely on
dynamic import in a cached build. Because a cache or snapshot can only be loaded on the platform where it was compiled, a cross-platform build must keep both
useCodeCache and useSnapshot set to false, or the executable may crash on start-up.
Signing on macOS and Windows
Injecting a blob invalidates the signature that ships with the official node binary, so the platforms that check signatures need the result signed again.
| Platform | What the documentation says | Observed here |
|---|---|---|
macOS |
Sign the binary after the build, for example with |
On Apple silicon, the freshly built executable was killed by the system (exit status 137, no output) until it was signed; after |
Windows |
Signing is optional: |
Not run (no Windows machine). Whether an unsigned executable triggers warnings for end users was not checked here. |
Linux |
No signing step in the documentation. |
Not applicable. |
# macOS: ad-hoc signature, enough to run on the build machine
codesign --sign - bookshelf
# Windows (PowerShell): needs a code-signing certificate that you manage outside the repository
signtool sign /fd SHA256 bookshelf.exe
Keep certificates, passwords and notarization credentials in the CI secret store and read them from the environment, never from the repository or the sea-config.json file.
The virtual file system for assets (useVfs)
Reading assets through getAsset() means rewriting every call that used to read a file. Since Node.js 26.9.0 (Stability 1.0, Early development) the
assets can instead be exposed as a read-only virtual file system through "useVfs": true, so ordinary node:fs calls and relative require() calls keep working.
The mount lives in a reserved namespace that cannot exist on the real file system, so it never shadows a real path, and the injected main script itself is run from the root of the
mount: dirname is that root. The mount point is chosen at run time, so code must always build paths from dirname (or import.meta.dirname in an ES module) and
never hard-code one.
const fs = require('node:fs');
const path = require('node:path');
const shout = require('./lib/shout.js');
const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8'));
console.log('name:', shout(config.name));
console.log('files:', fs.readdirSync(path.join(__dirname, 'public')));
console.log('exists optional:', fs.existsSync(path.join(__dirname, 'optional.json')));
console.log('__dirname inside VFS:', __dirname !== path.dirname(process.execPath));
try {
fs.writeFileSync(path.join(__dirname, 'public', 'new.txt'), 'x');
} catch (err) {
console.log('write ->', err.code);
}
{
"main": "app.js",
"output": "app",
"useVfs": true,
"disableExperimentalSEAWarning": true,
"assets": {
"config.json": "config.json",
"lib/shout.js": "lib/shout.js",
"public/index.html": "public/index.html"
}
}
name: BOOKSHELF
files: [ 'index.html' ]
exists optional: false
__dirname inside VFS: true
write -> EROFS
The relative require('./lib/shout.js') was resolved from the bundled assets, readdirSync() listed only the bundled entries and a write failed with EROFS
because the file system is read-only. ES module entry points are supported with "mainFormat": "module" (import.meta.dirname is the mount root), and name
bundled modules .mjs or bundle the relevant package.json files so their format is detected as on a real disk.
| Constraint | Detail |
|---|---|
No snapshot, no code cache |
|
Native add-ons |
|
Read-only |
Write operations fail with |
Not a security boundary |
The |
The node:vfs module on its own
node:vfs (Stability 1, Experimental, added in v26.4.0) is the general module behind useVfs: a virtual file system with a node:fs-shaped API, backed by memory by
default, which is useful for test fixtures and embedded assets. It is only available under the node: scheme and only when Node.js starts with --experimental-vfs; without the flag
the import fails with ERR_UNKNOWN_BUILTIN_MODULE. Mounting an instance with mount() returns a mount point under a reserved namespace; paths below it are served by the
virtual provider through the global node:fs, require() and import, and a using declaration unmounts automatically.
import vfs from 'node:vfs';
import fs from 'node:fs';
using fixture = vfs.create();
fixture.mkdirSync('/templates', { recursive: true });
fixture.writeFileSync('/templates/mail.txt', 'Dear reader');
const mountPoint = fixture.mount();
console.log(fs.readFileSync(`${mountPoint}/templates/mail.txt`, 'utf8'));
console.log('mount point reserved:', mountPoint.startsWith('/dev/null/vfs/'));
node --experimental-vfs v.mjs
Dear reader
mount point reserved: true
(node:92296) ExperimentalWarning: VirtualFileSystem is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Besides MemoryProvider the module has RealFSProvider (a directory) and ZipProvider (an archive), and the command line gained --vfs-mount and --vfs-load flags that mount a directory
or archive without code; the node:vfs page documents their details, and the mount point format is explicitly subject to change. On Node.js 24 and 22 the module and the flag do not exist, so
there the only asset route is the node:sea API. The /dev/null/vfs/ prefix in the example is an observation of one release, not a contract.
Limitations and pitfalls
| Area | Behaviour |
|---|---|
One script |
A SEA embeds a single script, CommonJS or (since the |
Module loading |
|
ES module details |
|
Same version and platform |
The binary receiving the blob must be the same version as the one that produced it. Cross-platform builds must disable the code cache and the snapshot. Native add-ons are not portable either. |
Native add-ons |
They cannot be |
Size |
The executable contains the whole |
Platform support |
CI tests Windows, macOS (arm64 only; x64 is not tested) and Linux (all supported distributions except Alpine, all architectures except s390x). Anything else is unsupported. |
Security |
The script and assets are embedded unencrypted and can be extracted by anyone who has the file: never embed secrets. Read them from the environment at run time. |
Updates |
Security fixes in Node.js reach your users only when you rebuild with a patched |
const { createRequire } = require('node:module');
const requireFromDisk = createRequire(__filename);
const plugin = requireFromDisk('./plugins/export-csv.cjs'); // resolved from the executable's directory
console.log(plugin);
try { require('./plugins/export-csv.cjs'); } catch (e) { console.log('plain require ->', e.code); }
plugins directory are copied to another folder{ name: 'csv' }
plain require -> ERR_UNKNOWN_BUILTIN_MODULE
The pattern is the one from the official page. It was built and run as a SEA, and the plugin file stays outside the executable, which is the opposite of what a self-contained build aims at, so use it sparingly. The plugin uses the .cjs extension so that the format does not depend on a package.json found on disk.
The legacy workflow with --experimental-sea-config and postject
Before --build-sea the build was three steps, and it is still the way to build on Node.js 24 and 22, where --build-sea does not exist. The first step writes a blob (in the configuration output names the
blob file instead of the final executable), the second copies the node binary and removes its signature on macOS and Windows, and the third injects the blob with the postject tool from
the Node.js organisation. The sentinel fuse string and the Mach-O segment name come from the official page; the example keeps the fuse in an environment variable so that you copy it from that page into your build script once.
# SEA_FUSE: the NODE_SEA_FUSE_... sentinel string quoted in the official documentation
echo '{ "main": "hello.js", "output": "sea-prep.blob", "disableExperimentalSEAWarning": true, "assets": { "banner.txt": "banner.txt" } }' > sea-config.json
node --experimental-sea-config sea-config.json # writes the blob only
cp "$(command -v node)" hello
codesign --remove-signature hello # macOS; on Windows: signtool remove /s hello.exe
npx postject hello NODE_SEA_BLOB sea-prep.blob \
--sentinel-fuse "$SEA_FUSE" \
--macho-segment-name NODE_SEA # --macho-segment-name only on macOS
codesign --sign - hello
./hello world
Start injection of NODE_SEA_BLOB in hello...
Injection done!
isSea: true | banner: Bookshelf on Node 24 | argv[2]: world
postject is an alpha-versioned package (1.0.0-alpha.6 was the npm release on the day of writing) that you install in your build tooling, never in the application. On Linux the
--macho-segment-name option is not used, and on Windows the blob target is a .exe copy of node.
What changed recently
| Version | Change |
|---|---|
v20.6.0 |
|
v21.7.0, v20.12.0 |
The |
v22.20.0, v24.8.0 |
|
v25.5.0 |
|
Node.js 26 line |
|
v26.4.0 |
The experimental |
v26.9.0 |
|
v26.10.0 |
|
Where the books are outdated
Efficient Node.js (Samer Buna) and Node.js Projects (Jonathan Wexler) are written for a time when shipping a Node.js program meant installing Node.js on the target
machine or reaching for a third-party packager. Neither describes the built-in single executable support, so a reader who stops there will miss that the runtime can now produce
a standalone binary with node --build-sea, bundled assets through node:sea, optional start-up snapshots and, on the newest line, a virtual file system. The workflow with
postject that circulates in many tutorials is the older two-step form, and it is no longer the recommended path on Node.js 25.5 and later. Use the books for the concepts
(module systems, start-up cost, why bundling helps) and this page for the current commands.
Summary
-
A SEA is the
nodebinary plus an injected blob: no Node.js on the target machine, but about 145 MB per file, and Stability 1.1 (Active development). -
On Node.js 25.5 and later, build with
node --build-sea sea-config.json; on 24 and 22 use--experimental-sea-configandpostject. -
Bundle the application into one script, set
mainFormatfor ES modules, and ship data files throughassetsandnode:sea(oruseVfson Node.js 26.9 and later). -
Sign the result on macOS (required to run on Apple silicon) and, where possible, on Windows; build on the target platform when using a snapshot or code cache.
-
Never embed secrets, pin the Node.js version, and rebuild when Node.js ships security fixes.