· 5 min read ·

Why Deno and Bun Don't Have Node.js's Single-Binary Problem

Source: hackernews

Node.js 22 shipped Single Executable Applications as stable. The pitch is straightforward: bundle your JavaScript into a blob, inject it into the Node.js binary, ship one file. For pure-JavaScript applications with no file-reading dependencies, it works well. For anything that includes packages like Prisma, Sharp, or any template engine that reads partials from disk, SEA hits a wall immediately.

Meanwhile, deno compile and bun build --compile produce standalone binaries where file I/O works without modification. The same readFile calls that work in development continue to work in the compiled binary. Understanding why comes down to a structural difference in how these runtimes are built.

What SEA Provides and What It Doesn’t

The SEA asset API is deliberately explicit. You declare files in a configuration, they get embedded into the binary blob, and your code retrieves them through a dedicated API:

{
  "main": "bundle.js",
  "output": "sea-prep.blob",
  "assets": {
    "schema.sql": "./db/schema.sql",
    "template.html": "./views/template.html"
  }
}
const { getAsset } = require('node:sea');
const schema = getAsset('schema.sql', 'utf8');

The SEA documentation is clear that fs calls are not intercepted. Code that does fs.readFile('./db/schema.sql') reaches the real filesystem, which in a standalone binary has none of the application’s files in it. Any dependency that reads its own configuration, templates, SQL files, or locale data using normal fs calls fails silently or throws ENOENT.

No npm package has ever used getAsset(). It did not exist before Node.js 20. For SEA to work transparently with the existing ecosystem, the fs module would need to route requests for embedded files to the blob automatically, without any caller needing to know about it.

Why Deno and Bun Don’t Have This Problem

Deno is written in Rust. Its V8 bindings, standard library, HTTP client, and file I/O layer all live in the same repository. When Deno.readTextFile('config.json') is called inside a compiled binary, the call goes from JavaScript into Deno’s Rust code, which decides where to get the bytes. For binaries produced with deno compile, the Rust layer checks whether the path matches an embedded file before falling through to the OS. There is no separate patch, no hook system, no monkey-patching. The routing decision happens inside the runtime, at the only layer that matters.

deno compile --include config.json --include templates/ main.ts

Files declared with --include are accessible at their original relative paths inside the compiled binary. Code that reads config.json with Deno.readTextFile('./config.json') works without modification, because the Rust code backing readTextFile contains the routing logic for the embedded archive.

Bun, written in Zig, works the same way. bun build --compile embeds the application and its dependencies into a standalone binary. Bun’s file system routing lives in Zig as part of the runtime, sitting between the JavaScript API surface and the OS. Embedded files are accessible through normal Bun file APIs because the layer that backs those APIs includes the routing decision.

Both runtimes own their stacks end to end. Adding VFS support meant adding it to one codebase, at the correct layer, once.

Why Node.js Cannot Do This Directly

Node.js’s file I/O call chain is:

JavaScript → lib/fs.js → src/node_file.cc → libuv → OS

There is no seam in this chain where userland code can insert alternative routing without patching one of the layers. The fs module is a concrete implementation backed by libuv. It is not an interface over a pluggable implementation; it is the implementation. Node.js has stabilized this API surface across fifteen years of production use, and that stability is a real constraint on what can change.

Vercel’s pkg tool worked around this by patching at process.binding('fs'), the internal C++ binding layer that connects lib/fs.js to node_file.cc. The patch intercepted every fs call before it reached libuv, routing paths inside the snapshot root to the embedded archive. From the application’s perspective, the filesystem was real. Dependencies loaded without modification. Native modules that needed actual paths on disk were extracted to a temporary directory at startup and redirected transparently.

The problem is that process.binding is an internal, unstable API. Node.js has progressively restricted access to internal bindings across major versions, hardening the boundary between user code and runtime internals. Maintaining the pkg patch required building a modified Node.js binary for each combination of version and target platform. When the maintenance burden outgrew the team’s capacity, Vercel deprecated pkg in 2023, pointing users toward SEA.

SEA replaced the transparent-VFS approach with the explicit getAsset() API. Whether that was the right tradeoff is the question driving the Platformatic proposal and its 200-comment Hacker News discussion.

The Cost in Practice

The impact is clearest with packages that ship native binaries or read data files as part of their initialization.

Prisma locates its native query engine binary at startup using fs.access() calls against platform-specific path patterns. In a pkg binary, those calls were intercepted and the native binary was extracted to a temp path automatically. In a SEA binary, there is no interception step. Prisma cannot find its engine and the application fails before serving a single request.

Sharp, the image processing library that depends on libvips, locates its native binding using filesystem operations that assume the binary is at a real path. It has the same failure mode, as does better-sqlite3 and most other packages that ship .node native addons. These packages are near the top of the npm download charts. A packaging story that cannot accommodate them covers a narrow slice of real Node.js applications.

What a Fix Would Require

The Platformatic post argues for a VFS hook at the binding layer, consistent with what pkg proved works and what Electron’s ASAR format has been doing in production since 2014. The minimum surface to intercept includes readFile and its synchronous and promise variants, createReadStream, stat, access, open/read/close, and readdir, along with their synchronous counterparts.

A JavaScript-level hook would handle most of this but miss native addons that call into libuv directly, bypassing the JavaScript layer entirely. Full coverage requires interception written in C, at the binding layer. ASAR operates at that layer in every Electron application that ships, including VS Code, Slack, and Discord. It is the existence proof that binding-level VFS interception is tractable for Node.js. The cost is that Electron maintains a forked Node.js binary to have it.

The engineering challenges are real: performance overhead of checking each fs call against a hook table, the sync constraint that prevents synchronous fs variants from going async through a hook, and the security model for virtual path registration. But they are not unsolved. pkg, ASAR, and the Module Customization Hooks API have each addressed different parts of the same set of problems. Combining those approaches into a stable, officially supported API is the remaining design work, not a proof of feasibility.

The Node.js module extensibility story ran from require.extensions through --experimental-loader to the stable module.register() API in Node.js 22, spanning roughly a decade of incremental design. A parallel track for fs extensibility is the natural next step. Deno and Bun closed this gap at founding time because they started fresh. Node.js has to close it from within a codebase that fifteen years of ecosystem depends on, which is harder, and which also means the fix matters more when it arrives.

Was this interesting?