| |

Node.js 82 🟢 Single Executable Applications (SEA) to Package Apps as Native Binaries

⏱️ Estimated read: 12 min | |

Node.js Single Executable Applications (SEA) is the runtime’s native answer to a problem that third-party tools like pkg and nexe spent years addressing: how do you ship a Node.js application to a machine that does not have Node.js installed? The approach is deceptively simple. You bundle your application into a single JavaScript file, generate a preparation blob that contains that file, and inject the blob into a copy of the Node.js binary itself. The result is a standalone executable that, when launched, checks for the injected blob and runs your application instead of the REPL or the script you passed on the command line .

The feature is still marked experimental in the Node.js documentation, and the creation process is explicitly described as “subject to change” . But the core mechanism has stabilized enough that it is the recommended path forward, and the ecosystem has begun to treat pkg as deprecated in favor of SEA . The practical workflow has three stages: bundle, generate, and inject. The bundle stage is the one most developers underestimate, because SEA does not read from the filesystem in the injected script. If your code does require('./config.json') or imports from node_modules, it will fail at runtime unless the entire dependency graph is flattened into a single file before injection .

Key point: A Node.js SEA is created by bundling the application into a single CommonJS file, generating a preparation blob with node --experimental-sea-config, and injecting that blob into a copy of the Node.js binary using postject; the injected script cannot read modules from the filesystem, so the bundling step is mandatory, not optional.


Why the bundling step is mandatory

The filesystem isolation problem. When the injected main script runs, require() and import do not read from the filesystem. They can load built-in modules like fs and path, but any attempt to load a module that exists only in node_modules throws an error . This is not a bug; it is the design. The executable is meant to be self-contained, and a self-contained executable cannot depend on a directory structure that may not exist on the target machine.

The bundler requirement. The solution is to use a bundler—esbuild, webpack, or rollup—to flatten the application and all its dependencies into a single JavaScript file. The bundler resolves every require and import at build time and emits one file that contains everything . The pkg tool’s SEA mode documentation describes this as the “simple SEA” variant: a single pre-bundled .js file that Node.js can inject directly .

The ESM complication. Node.js SEA supports CommonJS as the default mainFormat, and while the "module" format is available, it cannot be combined with snapshots . For projects that use ES modules natively, the practical path is to bundle with esbuild using --format=cjs, which converts the ESM graph into a CommonJS file that SEA can inject . The node2exe package’s documentation states the limitation bluntly: SEA “currently only supports running a single embedded script using the CommonJS module system,” and the solution for ESM projects is a bundler step .

The native addon exception. Native .node files cannot be loaded from the injected script directly. The workaround is to include them as assets in the SEA configuration, write them to a temporary file at runtime, and load them with process.dlopen() . This is clunky, and it means a project with native dependencies is not truly a single file—it is a single file plus a temporary extraction step. The documentation notes a known caveat where building on a Linux arm64 Docker container can produce a binary that crashes when loading addons .


a. Generating the preparation blob

The blob generation step reads a JSON configuration file that specifies the main script, the output blob path, and optional settings. The node --experimental-sea-config flag is the entry point.

{
  "main": "dist/bundle.js",
  "output": "dist/app.blob",
  "disableExperimentalSEAWarning": true,
  "useCodeCache": true,
  "useSnapshot": false
}

The main field points to the bundled file from the previous step. The output field is where the blob is written. The disableExperimentalSEAWarning flag suppresses the warning that the feature is experimental, which is appropriate once the pipeline is stable .

The useCodeCache option compiles the main script into V8 code cache during blob generation, which speeds up startup by skipping compilation on launch . The useSnapshot option goes further: it runs the main script at build time to initialize the heap, serializes that heap into the blob, and deserializes it at launch. The script must call v8.startupSnapshot.setDeserializeMainFunction() to configure the code that runs after deserialization . Both options are platform-specific, which is the critical constraint.

The generation command is:

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

The output is a .blob file. The version of Node.js used to generate the blob must match the version of the Node.js binary that will be injected . A mismatch does not always fail loudly; it can produce a binary that starts but behaves incorrectly .


b. Injecting the blob into the binary

The injection step copies the Node.js binary, removes any existing code signature (on macOS), and uses postject to insert the blob into a named section of the binary. The --sentinel-fuse value is a fixed string that Node.js uses to detect whether a blob has been injected.

# Linux
cp $(command -v node) dist/app
npx postject dist/app NODE_SEA_BLOB dist/app.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

On macOS, the binary must be re-signed after injection. The sequence is: remove the existing signature, inject, then re-sign with an ad-hoc signature for internal use or a Developer ID certificate for external distribution .

codesign --remove-signature dist/app
npx postject dist/app NODE_SEA_BLOB dist/app.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
  --macho-segment-name NODE_SEA
codesign --sign - dist/app

On Windows, the signature is invalidated by the injection, and postject may print a “signature seems corrupted” warning. The binary still runs, but for distribution to external users, it must be signed with signtool after injection .

The resulting executable is the Node.js binary with the blob embedded. Running it executes the bundled application. Running a stock Node.js binary without an injected blob still behaves as Node.js normally does .


c. The cross-platform and size constraints

The cross-platform limitation. V8 code cache and startup snapshots are platform-specific. A blob with useCodeCache: true compiled on darwin-arm64 will crash on linux-x64. The fix is to set both useCodeCache and useSnapshot to false when building for a different platform than the build machine . Without these optimizations, the executable still works, but startup is slower. The Node.js version constraint also applies: the blob and the binary must be the same version .

The size reality. A “hello world” SEA is roughly 80–90 MB because it contains the full Node.js runtime . This is smaller than an Electron application, but it is not small compared to a Go or Rust binary. The Docker image comparison in one experiment showed a full Node.js image at 235 MB and a minimal scratch-based image with the SEA binary at 156 MB . The size is the cost of embedding the runtime, and it is not avoidable with the native SEA mechanism.

The static asset problem. The assets field in the configuration allows files to be bundled into the blob and retrieved at runtime with sea.getAsset() or sea.getAssetAsRaw() . This works for files the application controls, but it does not solve the problem of third-party modules that read their own configuration files from node_modules at runtime. Those modules must be patched or their assets extracted and included manually .


Complete Example Session

The following session builds a minimal Express application into a SEA executable, starting from the bundle step and ending with a running binary.

// ============================================
// PART 1: THE APPLICATION
// ============================================
// src/app.js
const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.json({ status: 'ok', runtime: process.version });
});

app.listen(3000, () => console.log('Listening on :3000'));
# ============================================
# PART 2: BUNDLE TO A SINGLE COMMONJS FILE
# ============================================
npx esbuild src/app.js --bundle --platform=node --format=cjs --outfile=dist/bundle.js
// ============================================
// PART 3: THE SEA CONFIGURATION
// ============================================
{
  "main": "dist/bundle.js",
  "output": "dist/app.blob",
  "disableExperimentalSEAWarning": true,
  "useCodeCache": false,
  "useSnapshot": false
}
# ============================================
# PART 4: GENERATE THE BLOB
# ============================================
node --experimental-sea-config sea-config.json
# Output: dist/app.blob
# ============================================
# PART 5: INJECT AND RUN (LINUX)
# ============================================
cp $(command -v node) dist/app
npx postject dist/app NODE_SEA_BLOB dist/app.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
chmod +x dist/app

# Run the standalone executable
./dist/app
# Output: Listening on :3000

The five parts cover the application, the esbuild bundle, the SEA configuration, the blob generation, and the injection and execution. What the session demonstrates is that the bundle step is not optional—the injected script cannot load Express from node_modules, so esbuild must flatten it into the single file that SEA embeds.


Quick Reference

SEA Workflow

StepCommandOutput
Bundleesbuild app.js --bundle --format=cjsbundle.js
Configuresea-config.jsonConfig file
Generatenode --experimental-sea-config sea-config.jsonapp.blob
Injectpostject app NODE_SEA_BLOB app.blobStandalone executable

Configuration Fields

FieldDefaultPurpose
main—Bundled script path
output—Blob output path
mainFormat"commonjs""commonjs" or "module"
useCodeCachefalseV8 code cache (platform-specific)
useSnapshotfalseStartup snapshot (platform-specific)
disableExperimentalSEAWarningfalseSuppress warning

Injection Commands

PlatformExtra Flag
LinuxNone
macOS--macho-segment-name NODE_SEA + re-sign
WindowsNone (signature warning expected)

Constraints

ConstraintDetail
Filesystem modulesNot loadable from injected script
ESM support"module" format available, no snapshot
Native addonsExtract to temp file, process.dlopen()
Cross-platformuseCodeCache and useSnapshot must be false
Size~80 MB minimum (includes Node.js runtime)

Best Practices

✅ Do This:

esbuild app.js --bundle --format=cjs                  # ✅ single file bundle
node --experimental-sea-config sea-config.json       # ✅ matching Node version
postject app NODE_SEA_BLOB app.blob                  # ✅ correct sentinel fuse
codesign --sign - app                                 # ✅ re-sign after injection on macOS

❌ Don’t Do This:

require('./config.json') // in injected script         // ❌ no filesystem reads
import express from 'express' // without bundling      // ❌ module not found
// useCodeCache: true for cross-platform build        // ❌ crashes on target
// assume single file when native addons exist        // ❌ temp extraction needed

Common Pitfalls

PitfallWhy It HappensFix
Cannot find module at runtimeDependencies not bundledesbuild --bundle
Startup crash on different platformuseCodeCache/useSnapshot enabledSet both to false
Signature invalid on macOSInjection invalidates signatureRe-sign after postject
Blob/binary version mismatchDifferent Node versionsPin version in CI
Large executable sizeFull Node.js runtime embeddedExpected; ~80 MB minimum

Real-World Examples

1. Bundle with esbuild

npx esbuild app.js --bundle --platform=node --format=cjs --outfile=bundle.js

2. Minimal SEA config

{ "main": "bundle.js", "output": "app.blob", "disableExperimentalSEAWarning": true }

3. Generate blob

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

4. Inject on Linux

cp $(command -v node) app && npx postject app NODE_SEA_BLOB app.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

5. macOS signing

codesign --remove-signature app && npx postject app NODE_SEA_BLOB app.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 --macho-segment-name NODE_SEA && codesign --sign - app

6. Windows injection

npx postject app.exe NODE_SEA_BLOB app.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

7. Cross-platform build

{ "useCodeCache": false, "useSnapshot": false }

8. Asset retrieval

const { getAsset } = require('node:sea');
const config = getAsset('config.json', 'utf8');

9. Native addon extraction

const { getRawAsset } = require('node:sea');
fs.writeFileSync(tmpPath, new Uint8Array(getRawAsset('addon.node')));
process.dlopen({ exports: {} }, tmpPath);

10. Docker minimal image

FROM scratch
COPY --from=build /app/dist/app /app
ENTRYPOINT ["/app"]

Visual

SEA Build Pipeline

┌──────────────────────────────────────────────┐
│  Source code + node_modules                  │
│         │                                    │
│         ▼                                    │
│  BUNDLER (esbuild/webpack)                   │
│  Flattens dependency graph                   │
│         │                                    │
│         ▼                                    │
│  bundle.js (single CommonJS file)            │
│         │                                    │
│         ▼                                    │
│  node --experimental-sea-config              │
│  Reads sea-config.json                      │
│         │                                    │
│         ▼                                    │
│  app.blob (preparation blob)                 │
│         │                                    │
│         ▼                                    │
│  postject (inject into Node binary)          │
│         │                                    │
│         ▼                                    │
│  Standalone executable                       │
└──────────────────────────────────────────────┘

Why Bundling Is Required

┌──────────────────────────────────────────────┐
│  INJECTED SCRIPT                             │
│  require('express')                          │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│  built-in   node_modules                     │
│    │         │                               │
│    ▼         ▼                               │
│  ✅ works   ❌ throws                        │
│                                              │
│  Bundler resolves everything at build time   │
│  → single file, no filesystem reads          │
└──────────────────────────────────────────────┘

Platform-Specific Optimizations

┌──────────────────────────────────────────────┐
│  useCodeCache: true                          │
│  Compiled on darwin-arm64 → works there      │
│  Copied to linux-x64 → startup crash         │
│                                              │
│  useSnapshot: true                           │
│  Same platform-specific constraint            │
│                                              │
│  CROSS-PLATFORM FIX:                         │
│  Set both to false                           │
└──────────────────────────────────────────────┘

macOS Signing Sequence

┌──────────────────────────────────────────────┐
│  1. Copy Node binary                         │
│  2. codesign --remove-signature              │
│  3. postject (inject blob)                   │
│  4. codesign --sign -                        │
│                                              │
│  Out of order = invalid signature or         │
│  unsigned binary that fails Gatekeeper       │
└──────────────────────────────────────────────┘

Summary

ItemValue
FeatureSingle Executable Applications (SEA)
StatusExperimental
Blob generationnode --experimental-sea-config
Injection toolpostject
Default formatCommonJS
Bundler requiredYes (esbuild, webpack)
Size~80 MB minimum
Cross-platformuseCodeCache/useSnapshot must be false
Native addonsExtract to temp, process.dlopen()

Key takeaways:

  • SEA embeds a blob into the Node.js binary. The executable runs the injected script instead of the normal Node.js entry point. No Node.js installation is required on the target machine .
  • Bundling is mandatory, not optional. The injected script cannot read modules from the filesystem. A bundler must flatten the application and its dependencies into a single file before blob generation .
  • CommonJS is the default format. ESM projects must be bundled to CommonJS. The "module" format exists but cannot be combined with snapshots .
  • useCodeCache and useSnapshot are platform-specific. A blob built on one platform will crash on another if these are enabled. Set both to false for cross-platform builds .
  • The Node.js version must match. The blob and the binary it is injected into must be the same version. A mismatch can produce a binary that starts but misbehaves .
  • macOS requires re-signing after injection. The postject step invalidates the existing signature. Remove, inject, then sign .
  • The executable is large. It contains the full Node.js runtime. The minimum size is roughly 80 MB, which is the cost of self-containment .

Remember: Single Executable Applications are the native replacement for pkg and nexe, and the workflow is more constrained than those tools were. The bundle step is the one that determines whether the executable works at all, because the injected script has no filesystem access to node_modules. The blob generation and injection steps are mechanical once the bundle exists. The platform-specific optimizations are the ones that cause silent failures in cross-platform pipelines—a binary that works on the build machine and crashes on the target. The result is a large but self-contained executable that runs without Node.js installed, which is the entire point of the feature.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!