Node.js has had a clean story for intercepting module loading since v20.6: register your hooks, intercept resolve and load, redirect imports wherever you want. What it has never had is an equivalent abstraction one level down, at the filesystem. That gap is the subject of a recent post from Platformatic, and it explains a pattern that anyone who has shipped Node.js apps as single binaries will recognize: every tool that tries to embed files ends up writing its own filesystem layer, incompatibly, and fighting the runtime the entire way.
The Problem pkg Solved
The clearest illustration of what a virtual filesystem does for you is pkg, the now-archived Vercel tool for compiling Node.js applications into standalone executables. pkg’s approach was radical: it distributed pre-patched Node.js binaries, one per Node version and per platform, and embedded a virtual filesystem into those binaries with paths prefixed by /snapshot/.
What made this work was transparency. When you ran a pkg-compiled binary, __filename resolved to something like /snapshot/app/index.js. fs.readFileSync('./config.json') found /snapshot/app/config.json in the embedded store. require('lodash') resolved through the normal Node module resolution algorithm, but against the virtual snapshot. You did not need to change your application code. The entire node:fs surface, including readFile, readFileSync, stat, statSync, readdir, readdirSync, createReadStream, and access, was intercepted at the C++ binding level and redirected to the embedded store for /snapshot/ paths.
This worked because pkg had the luxury of patching the Node binary itself. It could intercept calls before they reached libuv. The patch was invasive and expensive to maintain; Vercel had to build and host patched binaries for every Node version and target platform, which is why the project was archived in 2023. The community fork @yao-pkg/pkg carries on, but the maintenance burden has not shrunk.
What Module Hooks Can and Cannot Do
Node.js v20.6 shipped module.register() and stabilized the module hooks API. You can now write a loader that intercepts every import and require, redirects them to an in-memory store, synthesizes source code on the fly, or implements an importmap without a third-party tool. This powers ts-node, tsx, @swc-node/register, and anything else that transforms source at load time.
// my-loader.mjs
export function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('virtual:')) {
return { shortCircuit: true, url: `node:virtual/${specifier}` };
}
return nextResolve(specifier, context);
}
export function load(url, context, nextLoad) {
if (url.startsWith('node:virtual/')) {
return { shortCircuit: true, format: 'module', source: `export default 42;` };
}
return nextLoad(url, context);
}
The hooks API stops at the module boundary. It intercepts import and require, meaning the module loading path. It does not intercept fs.readFile(), fs.createReadStream(), or fs.stat() called inside modules at runtime. A library that reads a JSON schema at startup using fs.readFileSync('./schema.json') is completely invisible to any hook you register. The module was loaded, but the filesystem call it makes runs through a separate channel, and there is no equivalent hook for that channel in Node.js.
Node.js SEA: Better Than nexe, Still Short of a VFS
Node.js Single Executable Applications landed experimentally in v20 and have matured through v21 and v22. The SEA API (node:sea) lets you embed assets into the binary and retrieve them at runtime:
import { getAsset } from 'node:sea';
const schema = getAsset('schema.json', 'utf8');
This is useful, but it requires you to change your application code. Every library or framework that reads files with fs.* needs to be either rewritten to use getAsset, pre-bundled so those reads disappear at compile time, or given real files on disk alongside the binary. SEA provides no mechanism to make fs.readFile('./schema.json') resolve to an embedded asset. The assets you embed are accessible only through the node:sea API; they do not participate in the normal filesystem.
SEA also requires a two-step pipeline: you must bundle your entire application into a single JS file before embedding it, because SEA only supports one entrypoint. Any library that lazily reads files at runtime rather than resolving them at bundle time will break unless those files exist on the real filesystem. Startup snapshot support (useSnapshot: true in the SEA config) exists but carries significant restrictions on what can appear in the snapshot, so it is not a general solution.
nexe takes the same position from the other direction: embed your bundled JS into a Node binary, but provide no transparent VFS for runtime fs.* calls. Assets appended to the binary are not reachable through normal filesystem paths. Both nexe and SEA inherited the same constraint by not attempting to solve it.
What Deno and Bun Got Right
Deno’s deno compile and Bun’s bun build --compile both work transparently because both runtimes were built with VFS as a first-class concept.
When Deno compiles a binary, it resolves the entire module graph at compile time, serializes it, and appends it to a copy of the Deno binary. More importantly, Deno.readFile() and Deno.readTextFile() work on embedded assets using the same paths you used at development time. The Deno runtime has a FileSystem trait, implemented in Rust inside the deno_fs crate, with an EmbeddedFileSystem implementation that activates when the binary detects it is running in compiled mode. There is no special API to call; the filesystem access is transparent. Deno has also supported cross-compilation via --target since early on, producing x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc, and Apple Silicon binaries from a single machine.
Bun does the same through its integrated bundler and runtime. Bun.file() resolves embedded assets the same way it resolves real files. Cross-compilation has been built in since Bun 1.0. Both runtimes support this because they treat the filesystem as an abstraction from the start. Node.js treats node:fs as a concrete binding to the OS, and everything built on top of it inherits that assumption.
The comparison matters beyond convenience. Deno and Bun can guarantee that compiled applications behave identically to their development counterparts because the same filesystem abstraction backs both. Node.js SEA cannot make that guarantee without a pre-bundle step that eliminates all runtime fs.* calls, which is often not achievable for applications that depend on third-party packages with their own file reads.
What a Node.js VFS Would Actually Require
The Platformatic argument, articulated by Matteo Collina, is that Node.js needs a FileSystem interface analogous to the module hooks API: a way to register a custom filesystem provider at startup, replacing the default OS-backed implementation. The mechanism might parallel --import for module hooks, something like --fs-provider=./my-fs.mjs.
This is non-trivial. node:fs calls are not just JavaScript wrappers; they invoke libuv directly from C++, and Node.js internals also call through those same bindings. A true VFS would need an interception point that currently has no official seam. Deno’s architecture makes this composable because its deno_fs::FileSystem trait is a Rust interface that all filesystem-accessing code passes through, rather than calling the OS directly.
The module hooks API demonstrated that Node.js can add pluggable interception points without compromising performance for the common case. The filesystem layer needs the same treatment, and the design precedent exists in the runtime’s own module loading architecture.
Beyond single-binary distribution, a VFS would unlock cleaner test isolation. Right now, memfs implements the full node:fs API in memory and is widely used via jest.mock('fs', () => require('memfs')) for testing code that reads files. But this mock only works for code that acquires fs through the module system; it cannot intercept native addons or Node.js internals that call the real filesystem through C++ bindings. A properly integrated VFS provider would close that gap as well.
The ecosystem has already voted. memfs, unionfs, pkg’s patched binaries, and SEA’s getAsset API are all proxies for the same missing feature. Maintaining all of them independently costs more in aggregate than building the abstraction once into the runtime, and Deno and Bun have already demonstrated that the model works at scale.