Node.js 20 shipped Single Executable Applications as an experimental feature, and by Node.js 22 the core mechanism had stabilized. The pitch is straightforward: bundle your application into a blob, inject it into a copy of the Node.js binary using postject, and ship one file. The Platformatic team recently published a detailed argument for why this falls short for most real-world applications, and the reason comes down to one missing layer: a virtual filesystem.
What SEA actually gives you
The SEA configuration is minimal. You write a sea-config.json that points to a bundled script and optionally maps asset keys to file paths:
{
"main": "bundle.js",
"output": "sea-prep.blob",
"assets": {
"schema.sql": "./db/schema.sql",
"template.html": "./views/index.html"
}
}
Running node --experimental-sea-config sea-config.json produces the blob. You copy your Node.js binary, inject the blob with postject, handle code signing on macOS, and you have an executable. The node:sea module lets you read those embedded assets at runtime:
const { isSEA, getAsset } = require('node:sea');
if (isSEA()) {
const sql = getAsset('schema.sql', 'utf8');
// use sql...
}
The critical detail is getAsset. Reading embedded files requires calling this function explicitly. There is no interception of the normal fs module. When your code calls fs.readFile('./db/schema.sql', ...), Node.js looks for that file on the real host filesystem, finds nothing, and errors. Every piece of application code that reads files must be rewritten to use getAsset instead.
For a greenfield CLI tool or a minimal application you control entirely, this is manageable. For anything with dependencies that read their own assets, it is a rewrite problem that extends into code you do not own.
The layering problem
Understanding why transparent VFS is hard in Node.js requires tracing how a fs.readFile() call executes:
Application JavaScript
→ Node.js fs module (JavaScript)
→ fs binding (C++ / src/node_file.cc)
→ libuv (C)
→ OS filesystem
Node.js’s module customization hooks, introduced experimentally in Node 18.6 and stabilized via module.register() in Node 20.6, intercept import and require resolution. They operate between the first and second layers. They can redirect module lookups but have no visibility into fs.readFile, fs.stat, fs.createReadStream, or any of the other filesystem calls that application code and dependencies make constantly. A loader hook that intercepts import './template.html' does nothing for fs.readFile(path.join(__dirname, 'template.html')).
To intercept fs calls transparently, you need to reach the C++ binding layer or below. That is outside what JavaScript can do without modifying the runtime itself.
What pkg got right
pkg, originally created by Vercel (then Zeit) and now maintained by the community as @yao-pkg/pkg, took the only approach that works: it ships a patched copy of Node.js.
At build time, pkg downloads a pre-compiled, patched Node.js binary for each target platform (linux-x64, win-x64, macos-x64, and others) and embeds your application’s file tree as a snapshot dictionary, mapping virtual paths to byte buffers. The patched runtime intercepts calls at the C++ binding level, checks whether a requested path exists in the snapshot, and serves the bytes from memory if it does. From the application’s perspective, nothing changed:
// This runs identically in development and in a pkg binary.
// No code changes, no special APIs.
const html = fs.readFileSync(path.join(__dirname, 'views/index.html'), 'utf8');
const config = require('./config/defaults.json');
__dirname and __filename are remapped at build time to point into the virtual snapshot root, so relative path construction continues to work. Dependencies that read their own data files work without modification. This transparent interception is the property that node:sea’s getAsset API cannot provide.
Vercel deprecated pkg in 2023, pointing users toward Node.js SEA. The community fork @yao-pkg/pkg continues to maintain pre-built binary sets for current Node.js releases, but the fundamental burden that caused Vercel to stop, building and hosting patched binaries for every Node.js version and every target platform, has not gone away. It has just been transferred to volunteers.
Electron’s answer: ASAR
Electron solved this problem years before any of the above tooling existed, and its solution is the most production-hardened VFS implementation in the Node.js ecosystem. The ASAR format is a sequential archive: a JSON header describing files with their byte offsets, followed by concatenated file data without compression, allowing fast random access without decompression overhead.
Electron patches Node.js’s fs binding in its own fork so that paths containing .asar are transparently redirected into the archive. The interception covers the full fs surface: readFile, stat, readdir, createReadStream, and everything else. Native addons, which need real filesystem paths because dlopen cannot load from memory, are handled via an app.asar.unpacked/ sidecar directory that ASAR paths redirect to transparently.
ASAR has never been upstreamed into Node.js core for a combination of reasons: Electron owns its own Node.js fork and can patch at will, the archive format is Electron-specific, and there has been no consensus on what a standard VFS API in Node.js should look like. ASAR demonstrates that the technical problem is solved; the remaining work is design and governance.
What Deno and Bun do differently
Both Deno and Bun produce single-binary executables with transparent VFS because they own their entire runtime stacks.
deno compile embeds the full module graph into the binary. The --include flag adds arbitrary files that remain accessible via the normal Deno.readFile() and Deno.readTextFile() APIs at their original paths. Deno’s runtime, written in Rust, can intercept filesystem access at whatever layer is appropriate because the entire call chain is internal code.
Bun’s bun build --compile does the same. Assets imported statically in your code are embedded and accessible transparently. Bun’s runtime, written in Zig, owns its own filesystem implementation, so VFS interception is not a patching problem; it is a routing problem with a clear internal solution.
This is the structural asymmetry worth noting. Node.js is a runtime whose internals become patching targets for anyone who needs VFS. Deno and Bun built new runtimes and got transparent single-binary packaging because they designed the whole stack from scratch.
The memfs dead end
The npm ecosystem has capable in-memory filesystem libraries. memfs implements the full Node.js fs API backed by a JavaScript object tree. unionfs overlays multiple fs instances:
const { ufs } = require('unionfs');
const { fs: mem } = require('memfs');
mem.mkdirSync('/assets', { recursive: true });
mem.writeFileSync('/assets/template.html', '<h1>Hello</h1>');
ufs.use(mem).use(require('fs'));
// ufs.readFileSync resolves from mem first, then the real fs
These libraries operate at the JavaScript layer. You can use ufs as your fs module and it works correctly. The problem is that nothing makes application code or dependencies use ufs instead of require('fs'). You can monkey-patch the fs module object, but native addons call into libuv directly, bypassing the JavaScript layer entirely, so any native module in your dependency tree will break.
Monkey-patching fs is also fragile enough that it is not a production strategy. These libraries are testing tools, and that is the use case they are designed for.
What a real VFS hook would require
The Platformatic post argues for a filesystem provider registration API in Node.js core, conceptually similar to module.register() but operating at the fs binding layer. A sketch of what that might look like:
import { register } from 'node:fs/hooks';
register(new URL('./my-vfs-provider.mjs', import.meta.url));
With a provider interface that receives filesystem requests before they reach libuv, returning virtual data or passing through to the real filesystem. This is architecturally similar to how FUSE works at the OS level. The engineering challenge is that libuv runs filesystem operations on a thread pool, so any such API needs to manage the boundary between the JavaScript event loop and the thread pool carefully to avoid introducing subtle concurrency bugs or performance cliffs.
An alternative path is extending node:sea with a VFS-aware asset resolution mode: if a call to fs.readFile produces a path that matches an embedded asset key, serve the bytes from the blob automatically. This would be narrower in scope than a general VFS hook but would solve the most common packaging use case without requiring changes to the fs binding architecture.
Whether Node.js core pursues either direction, builds only incrementally on getAsset, or leaves the space to community tooling like @yao-pkg/pkg is an open question. Every prior tool that solved this problem did so by patching Node.js internals outside any official API surface, which makes a sanctioned VFS hook the natural next step if Node.js wants single-binary distribution to be a first-class feature rather than a best-effort one.