Node.js fs Has No Interface, and Single Executable Applications Are Paying for It
Source: hackernews
There is a new proposal from Matteo Collina arguing that Node.js needs a virtual file system layer. The argument is correct. What makes it interesting is not the proposal itself but what it exposes about a design decision made fifteen years ago that nobody called out at the time.
The Node.js fs module is a concrete implementation backed by libuv. It is not an interface over pluggable backends. There is no seam between the JavaScript surface and the C++ binding layer where a userland provider can be inserted without patching the runtime. That is the architectural root of the problem, and it is now blocking Single Executable Applications from working with real-world dependencies.
The Regression Nobody Talks About
Before SEA there was pkg. Vercel’s tool distributed pre-patched Node.js binaries and embedded an application’s file tree as a virtual snapshot, storing files under a prefix like /snapshot/ on Linux and C:\snapshot\ on Windows. The patch happened at the C++ binding layer in src/node_file.cc, intercepting calls before they reached libuv. If the requested path existed in the snapshot dictionary, it served bytes from memory. If not, it fell through to the real filesystem.
The result was fully transparent. You could write:
const html = fs.readFileSync(path.join(__dirname, 'views/index.html'), 'utf8');
const config = require('./config/defaults.json');
and those lines would work identically in development and inside a bundled binary. Dependencies like Prisma, Sharp, and better-sqlite3 that read native binaries from disk at startup worked without modification. Template engines that discovered partials by walking a directory worked without modification. Nothing in the dependency tree needed to know it was running from a virtual snapshot.
pkg was deprecated by Vercel in September 2023 because maintaining pre-patched binaries for every Node.js version and every target platform was unsustainable. The community fork @yao-pkg/pkg continues, but it carries the same maintenance burden.
Then came Single Executable Applications in Node.js 22, stabilized and ready for production. The sea-config.json format lets you declare assets:
{
"main": "bundle.js",
"output": "sea-prep.blob",
"assets": {
"schema.sql": "./db/schema.sql",
"template.html": "./views/index.html"
}
}
And the node:sea module exposes a retrieval API:
const { getAsset } = require('node:sea');
const sql = getAsset('schema.sql', 'utf8');
This is a meaningful step. It is also a step backwards from what pkg provided. getAsset() is entirely disconnected from the fs module. A call to fs.readFile('./db/schema.sql') goes to libuv, finds nothing on disk, and errors. Every call site that reads embedded assets must be explicitly rewritten. Dependencies cannot be rewritten. SEA works for applications with zero file-reading dependencies, which is not most applications.
Why There Is No Hook Point
The call path for fs.readFile is:
fs.readFile()
→ lib/fs.js (JavaScript: argument validation)
→ src/node_file.cc (C++ binding layer)
→ libuv (C: async I/O thread pool)
→ OS syscall
There is no layer in that chain where a JavaScript-level provider can be inserted without patching the runtime. Node.js does expose module customization hooks, stable as of Node.js 22, which let you intercept import and require resolution:
export async function load(url, context, nextLoad) {
if (url.startsWith('virtual:')) {
return { shortCircuit: true, format: 'module', source: `export default 42;` };
}
return nextLoad(url, context);
}
But module hooks intercept module loading, not filesystem I/O. A dependency that gets loaded through a module hook can still call fs.readFile internally, and that call travels down to C++ with no further interception possible. The seam for modules exists because module resolution is ultimately a JavaScript operation the runtime controls at the JavaScript layer. Filesystem I/O skips that layer entirely.
Libraries like memfs and mock-fs have worked around this by patching internal C++ bindings via process.binding(). That approach worked through Node.js 16 and broke progressively as internal bindings were removed. Node 22 removed several of them entirely. Monkey-patching require('fs') at the JavaScript layer covers code you control and nothing else; native addons calling uv_fs_open directly are invisible to any JavaScript-level patch.
Electron Already Solved This
Electron ships production applications to hundreds of millions of users using a format called ASAR. It is a sequential archive: a JSON header describing files with byte offsets, followed by concatenated file data without compression, allowing fast random access. Electron’s fork of Node.js patches src/node_file.cc so that paths containing .asar transparently redirect into the archive. The full fs surface is covered: readFile, stat, readdir, createReadStream.
VS Code, Slack, and Discord all ship this way. It is not an experiment. Binding-level VFS interception works at scale and handles real dependency trees. Electron has not pushed this upstream because it owns its own Node.js fork and the format is specific to Electron’s use case, but the proof of concept is more than a decade old.
Go’s io/fs Did This Right
When Go 1.16 shipped in February 2021, it introduced io/fs and embed simultaneously. The core interface is minimal:
type FS interface {
Open(name string) (File, error)
}
Every standard library function that accepts files was updated to accept fs.FS. The result:
//go:embed templates/*.html config/*.json
var content embed.FS
data, _ := content.ReadFile("config/default.json")
tmpl, _ := template.ParseFS(content, "templates/*.html")
http.FileServer(http.FS(content))
os.DirFS wraps the real filesystem. embed.FS contains compile-time embedded files. fstest.MapFS provides an in-memory implementation for tests. All three satisfy the same interface. Code that accepts fs.FS works with any of them without modification.
The key was shipping the abstraction and the ecosystem adoption at the same time. Go did not introduce io/fs and then wait for the standard library to catch up. The interface was defined alongside embed, and the standard library was updated to use it before the release. That coupling is what made it useful immediately.
Node.js fs predates single-binary compilation as a deployment target, edge runtimes, in-process test isolation, or any of the use cases that make a VFS layer valuable. It was designed to expose OS primitives, not abstract them. That was a reasonable design for 2009. It accumulates debt every year the ecosystem grows without it.
The Infrastructure Already Exists
This is the part of Collina’s proposal that deserves more attention. Node.js 20 shipped an experimental permission model behind --experimental-permission. When you run:
node --experimental-permission --allow-fs-read=/specific/path app.js
the restriction is enforced by wrapping the same uv_fs_* calls that pkg patched. The insertion point lives in src/permission/ and node_file.cc. It checks requested paths against a permission manifest before allowing the operation.
The call chain already looks like:
JavaScript → C++ binding → permission check → libuv
A VFS hook would use the same architecture. Instead of blocking a path, it would redirect to a registered handler. The hard work of finding the right insertion point, covering all fs entry points including sync variants, and threading it through the binding layer is complete. It is not exposed as a general-purpose hook.
Deno and Bun sidestep this entirely because they own their full stacks. Deno’s deno compile --include config.json makes embedded files accessible at their original relative paths through the Rust FileSystem trait in the deno_fs crate. Bun’s bun build --compile does the same in Zig. Both runtimes designed VFS as a first-class concern from the start, not as a retrofit. Node.js cannot do that without breaking fifteen years of stable API surface.
What Node.js can do is expose what the permission model already built. A registration API analogous to module.register() operating at the fs binding layer would let SEA activate transparent asset resolution, let test frameworks mount in-memory filesystems without monkey-patching, and let edge runtimes register whatever storage backend they have available. The proposal is asking for exactly that: not a new abstraction invented from scratch, but a hook over infrastructure Node.js core already ships.
The practical constraints are real. Synchronous fs variants cannot call async JavaScript providers without blocking the event loop. Worker threads require independent provider registration. The no-VFS path through the hook table must be measurably close to zero overhead or every fs call in every application pays the cost. These are solvable problems with known solutions; they are also why this is a multi-release commitment, not a weekend project.
The pkg era proved that transparent VFS in Node.js is tractable. Electron proved it scales to production. Go proved that interface-first filesystem design enables an ecosystem to adopt it without retrofitting. The remaining question is whether the Node.js TSC treats this as core infrastructure or defers it until the next tool that works around it is deprecated.