Node.js Single Executable Applications (SEA): What Actually Breaks in Production
The pitch is clean: ship a Node.js app as a single binary, skip the npm install on the server, stop worrying about runtime version drift. For a simple CLI tool, SEA delivers exactly that. The gap opens the moment you try to deploy something real. Most teams hit the same six problems within the first week — not because they missed the docs, but because the docs present edge cases as footnotes and production constraints as implementation details. This article covers what actually breaks, why it breaks, and what you can do about it.
- SEA does not bundle
node_modules— a bundler step is mandatory, not optional - Native addons (
.nodefiles) cannot be embedded; the build succeeds and the binary fails at runtime - Dynamic
import()throws whenuseCodeCache: trueis set - Code cache and startup snapshots are platform-specific — cross-platform builds silently break
- The Node.js version used to generate
sea-prep.blobmust exactly match the binary being patched - On macOS and Windows, injecting the blob invalidates any existing signature; the signing sequence is strictly ordered
From pkg to node:sea — Why Engineers Are Switching Right Now
Vercel archived pkg in 2024. The community fork (@yao-pkg/pkg) kept pace for a while, but Node 22 introduced enough internal changes to make maintenance genuinely painful. nexe has similar staleness problems — last meaningful activity was years ago, and it still doesn’t handle recent Node versions reliably.
SEA is maintained by the Node.js core team and ships with Node 20+ LTS. The switch is logical. The problem is that engineers coming from pkg expect feature parity, and they don’t get it.
pkg resolved and bundled node_modules automatically. You pointed it at an entry file, it figured out the dependency tree, and you got a working binary. SEA does not do this. That single difference explains the majority of migration pain. Everything else — code signing, cross-platform builds, native addons — is a secondary problem. The bundler is the primary one.
Pitfall #1 — The Bundler Is Not Optional
Inside a SEA binary, require() can only load built-in Node.js modules. It does not resolve paths to node_modules at runtime. There is no filesystem to reach into — the binary is a sealed blob. Attempting to load express, lodash, or any third-party module throws immediately.
Engineers migrating from pkg consistently hit this on the first run. The binary builds without errors. It crashes on the first require('express'). The error message points at a missing module, not at a SEA-specific constraint, so the first instinct is usually to debug module resolution rather than add a bundler step.
esbuild — The Minimal Config That Works for SEA
Three esbuild flags are non-negotiable for SEA compatibility:
esbuild src/index.js
--bundle # resolve and inline all require() calls
--platform=node # don't polyfill Node built-ins
--format=cjs # SEA requires CommonJS output
--outfile=dist/bundle.js
--bundle resolves the entire dependency tree into a single file. Without it, you get the same crash as before. --platform=node tells esbuild to treat Node built-ins as external — require('fs') stays as require('fs') instead of being inlined or polyfilled. --format=cjs is required because the SEA blob expects a CommonJS entrypoint by default; ESM adds constraints covered in Pitfall #3.
Why webpack Needs target: 'node'
webpack’s default compilation target is browsers. Without target: 'node', it polyfills built-in modules — os, path, fs — with browser-compatible shims. The result is a bundle that is larger than necessary, slower to execute, and broken in specific ways that only surface at runtime.
// webpack.config.js
module.exports = {
target: 'node', // critical — prevents built-in polyfilling
entry: './src/index.js',
output: {
filename: 'bundle.js',
libraryTarget: 'commonjs2',
},
};
The dangerous part is that the binary can pass a smoke test. Code paths that don’t touch the polyfilled modules work fine. Code paths that do — typically anything using filesystem permissions, process signals, or native crypto — fail in production under specific conditions. That failure profile is expensive to debug.
Pitfall #2 — Native Addons Are a Hard Blocker
Native addons — compiled C++ modules loaded as .node files — cannot be embedded in the SEA blob. This is not a temporary limitation. The binary format of .node files depends on being loaded from a filesystem path by the OS dynamic linker. There is no mechanism to load them from memory inside a sealed binary.
The build succeeds. The binary is produced. It fails at runtime when the code path that requires the native addon is exercised. If that path isn’t hit by your smoke test, the failure reaches production.
Node.js Uncaught Exceptions and Process Crash Anatomy: What Actually Kills Your App Half your Node.js crashes trace back to three root causes — and none of them announce themselves cleanly. A Promise nobody .catch()-ed. An...
The affected surface is large: bcrypt, sharp, sqlite3, canvas, most database drivers with native bindings, uWebSockets.js, anything that shows a .node file in its package directory. If any dependency anywhere in the tree uses a native addon, you have a problem.
__dirname and process.execPath — Paths Inside a SEA Binary
Inside an injected script, __dirname resolves to the directory containing the SEA executable — equivalent to path.dirname(process.execPath) — not the original source directory. This matters when code constructs paths to load native addons at runtime.
const { isSea } = require('node:sea');
const path = require('path');
// inside a SEA binary, __dirname is the executable's directory
const addonPath = isSea()
? path.join(path.dirname(process.execPath), 'bcrypt.node')
: path.join(__dirname, '../build/Release/bcrypt.node');
The dlopen Workaround Pattern
The correct workaround is to ship the .node files alongside the binary and load them explicitly at runtime via process.dlopen. Detect sea.isSea(), resolve the addon path relative to process.execPath, and load it manually.
The trade-off is obvious: you no longer have a self-contained binary. Your deployment artifact is the executable plus a directory of .node files. For teams whose primary motivation for SEA was zero-dependency distribution, this partially defeats the goal. If native addons are central to your application, SEA may not be the right packaging strategy at all.
Pitfall #3 — ESM, import(), and the Module Format Trap
SEA supports both CJS and ESM entrypoints. The constraints are not symmetrical. ESM entrypoints cannot use useSnapshot. Dynamic import() breaks when useCodeCache: true. These are not obscure edge cases — they are the configurations engineers reach for when optimizing startup time, which is one of the main reasons to consider SEA for CLI tools.
useCodeCache: true Silently Breaks import()
When useCodeCache is enabled, any dynamic import() call at runtime throws. The warning is in the docs, but it’s easy to miss — and easy to miss the implications even when you read it.
The specific failure mode: modern packages increasingly use dynamic import() internally for lazy loading or ESM interop. Your own code may not contain a single import() call, but a transitive dependency does. The binary builds and starts. The crash happens when that dependency’s code path is executed. Adding a bundler doesn’t necessarily remove the dynamic imports if they’re used for runtime lazy loading.
// sea-config.json
{
"main": "dist/bundle.js",
"output": "sea-prep.blob",
"useCodeCache": false, // safest default; set true only if you've audited all dynamic imports
"useSnapshot": false
}
ESM + useSnapshot Is Still Unsupported
As of Node 22 and 24, "mainFormat": "module" and useSnapshot cannot be combined. Startup snapshots — backed by v8.startupSnapshot.setDeserializeMainFunction() — require the module system to be in a specific state at snapshot time that ESM initialization doesn’t produce.
For teams relying on ESM-native packages, this rules out snapshot-based startup optimization entirely. If startup latency is the problem you’re trying to solve — common for CLI tools where every millisecond is visible — and your bundle is ESM, snapshots aren’t available to you.
Pitfall #4 — Cross-Platform Builds Break With useCodeCache and useSnapshot
V8 code cache and startup snapshots are platform-specific binary blobs. A blob compiled on darwin-arm64 causes a startup crash on linux-x64. The fix is one line — set both to false in sea-config.json — but the failure mode is confusing enough that engineers spend real time debugging it.
The scenario that causes the most pain: a macOS developer building a binary for Linux deployment. The build completes on the Mac. A quick local test passes. The binary crashes on the server with an error that doesn’t obviously reference platform incompatibility. By default, useCodeCache is false, so this only bites teams that explicitly enabled it for performance — but those are exactly the teams doing careful optimization work who least expect a silent cross-platform break.
| Setting | Cross-platform safe? | Startup benefit |
|---|---|---|
useCodeCache: false, useSnapshot: false |
Yes | None |
useCodeCache: true, useSnapshot: false |
No — platform-specific blob | Moderate |
useCodeCache: false, useSnapshot: true |
No — platform-specific blob | Significant |
The Node.js version constraint compounds this. The version of Node used to generate sea-prep.blob must exactly match the version of the Node binary being patched with postject. A mismatch doesn’t always fail loudly. It can produce a binary that starts but behaves incorrectly — wrong module resolution behavior, unexpected crashes in specific code paths, silently skipped initialization. Pin the Node version in CI and don’t let it drift.
Why Most Node Devs Pick the Wrong Tool Between Cluster and Workers You're staring at a single-threaded Node process that's using 12% of your 8-core server. Someone on the team suggests cluster module. Someone else...
Pitfall #5 — Code Signing on macOS and Windows Is Not Optional for Distribution
When a SEA binary is distributed to end users — internal tooling shipped to other teams, CLI tools for external users — code signing stops being optional on macOS and becomes practically mandatory on Windows for SmartScreen trust. This is not SEA-specific. What is SEA-specific is that the injection step invalidates any existing signature on the Node.js binary you copied as your base.
The correct sequence is: copy the Node binary → remove existing signature → inject the SEA blob → re-sign. Doing it out of order produces a binary with a valid signature over an unmodified executable (unsigned SEA) or a binary that fails Gatekeeper because the content changed after signing.
macOS codesign — Remove, Inject, Re-sign
Remove existing signature before injection
codesign --remove-signature ./my-app
Inject the blob (postject or --build-sea handles this)
npx postject ./my-app NODE_SEA_BLOB sea-prep.blob
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
--macho-segment-name NODE_SEA
Re-sign after injection
codesign --sign - ./my-app # ad-hoc signing for internal distribution
# or, for distribution outside your org:
# codesign --sign "Developer ID Application: Your Name" ./my-app
Ad-hoc signing (--sign -) works for internal tooling distributed within a team on the same Apple ID or MDM policy. For anything distributed externally, you need a Developer ID Application certificate. Gatekeeper behavior differs: ad-hoc signed binaries are quarantined on first run for external users; Developer ID-signed binaries pass automatically if notarized.
Windows signtool — Optional But Recommended
On Windows, an unsigned binary runs — once the user dismisses the SmartScreen prompt. For internal tooling distributed via IT-managed machines, this is usually tolerable. For anything distributed to external users or through channels that check signatures, SmartScreen will block the first run with a warning that reads as malware to non-technical users.
The signing step on Windows is conceptually the same: the binary must be signed after injection, not before. A pre-injection signature is immediately invalidated by postject. Signing with signtool after injection is straightforward if you have an EV or OV code signing certificate from a trusted CA.
What a Production-Ready SEA Pipeline Actually Looks Like
The previous five pitfalls describe individual failure modes. Together they define what a production pipeline must account for. The list is longer than most teams expect:
- A bundler step producing a single CJS file — esbuild with
--bundle --platform=node --format=cjsor webpack withtarget: 'node' useCodeCache: falseanduseSnapshot: falsefor any cross-platform build- A native addon audit — identify every
.nodefile in the dependency tree before committing to SEA - Native addons extracted and shipped alongside the binary, loaded via
process.dlopenwithsea.isSea()detection - Node version pinned identically in blob generation and in the binary being patched — enforce this in CI, not just in documentation
- Platform-specific signing steps after injection
- Separate CI jobs per target platform — not a single build-once job
What this list reveals: a real SEA pipeline is more complex than a Dockerfile. For server-side applications, Docker is the simpler path. There is no contest. SEA’s genuine value is for CLI tools, internal scripts, and deployments where container infrastructure is the burden being avoided — air-gapped environments, edge nodes, IoT, developer tooling that needs to run on machines without Docker.
Node.js SEA in CI/CD — What the Pipeline Matrix Looks Like
A production CI matrix for SEA needs three platform-specific jobs: Linux x64, macOS arm64, Windows x64. Each job runs on a native runner for that platform — cross-compilation with code cache or snapshots enabled is not viable, and even without them, testing on the target platform catches path separator issues and OS-specific behavior.
Each job: install the pinned Node version → bundle with esbuild → generate the blob → inject with postject → sign for the target platform → upload the signed binary as a build artifact. The signing step lives at the end of each platform job, not in a separate post-processing job, because the certificate and signing tools are platform-specific.
The SEA lambda cold start case is worth evaluating separately. If startup time is measurable and the Lambda environment is predictable (single architecture, pinned runtime), snapshot-based optimization is viable — but it requires a dedicated build job for that specific target, not a shared artifact from the general pipeline.
Node.js Async Hooks Deep Dive: When Your Request ID Vanishes Mid-Fligh You've traced the bug for two hours. The request ID is there at the controller, gone by the time you hit the database logger....
SEA vs Docker vs pkg — When to Actually Use Each
| Use Case | Recommendation |
|---|---|
| Server-side API / microservice | Docker. SEA complexity outweighs the benefits here. |
| Internal CLI tool distributed to developers | SEA — strong fit, especially without native addons |
| CLI tool for external distribution | SEA + signing pipeline — viable, but budget for the setup |
| Lambda / serverless cold start optimization | SEA worth evaluating if startup latency is measured and significant |
Legacy pkg migration |
SEA is not a drop-in — audit native addons and add the bundler step before estimating effort |
SEA is not a general-purpose Docker alternative. It is a good tool for a specific, narrower set of scenarios. Teams that ship CLI tools to other engineers or need zero-dependency internal tooling get real value from it with manageable friction. Teams deploying server-side services are trading a known complexity (containerization) for a more obscure one (SEA pipeline + signing + addon handling) without a meaningful operational benefit.
FAQ
Can Node.js SEA bundle node_modules automatically?
No. SEA takes a single pre-bundled JavaScript file. It does not resolve require() calls to node_modules at runtime. You must run esbuild or webpack first to produce a single file that inlines all dependencies.
Why does my SEA binary crash on Linux when it was built on macOS?
If useCodeCache or useSnapshot is set to true in sea-config.json, the generated blob contains platform-specific V8 binary data. A blob built on darwin-arm64 will crash on linux-x64. Set both to false for cross-platform builds.
Does node:sea support native addons like bcrypt or sharp?
Not directly. .node files cannot be embedded in the SEA blob. The workaround is shipping the .node files alongside the binary and loading them via process.dlopen, using sea.isSea() to detect the runtime context and resolve the correct path.
Does useCodeCache: true break import()?
Yes. When useCodeCache is enabled, any dynamic import() call throws at runtime. This includes dynamic imports inside third-party dependencies, not just your own code. Use useCodeCache: false unless you’ve audited the entire dependency tree for dynamic imports.
Is SEA a replacement for vercel/pkg?
Not a drop-in replacement. pkg bundled node_modules automatically. SEA requires a separate bundler step. If your codebase uses native addons or relies on pkg‘s automatic module resolution, budget time for migration before committing to SEA.
What Node.js version does the SEA blob need to match?
The Node version used to generate sea-prep.blob must exactly match the version of the Node binary you patch with postject. A mismatch can produce a binary that starts but behaves incorrectly. Pin the version in CI using .nvmrc or an equivalent mechanism.
Do I need to sign a SEA binary on macOS?
For internal distribution within a team, ad-hoc signing (codesign --sign -) is sufficient. For external distribution, a Developer ID Application certificate is required to pass Gatekeeper without a user warning. The signing step must come after blob injection — injecting after signing invalidates the signature.
SEA solves a real problem, and the native implementation is more sustainable than pkg or nexe ever were. The issue is not the feature — it’s that the documentation presents it as simpler than it is in production. The pitfalls above are not edge cases. They are the default experience for any team deploying a non-trivial application. The teams that ship successfully are the ones who go in knowing the bundler is required, native addons need a separate strategy, and cross-platform builds need their own CI jobs. The teams that spend days debugging are the ones who expected a pkg --target node20 equivalent and got something architecturally different instead.
Written by: