node:vfs: what actually landed

Node.js apps usually expect their files to be on disk. They import modules, read configuration, and open templates by path. This works fine until you want to package everything in a ZIP file, a single executable, or generate files in memory. Even if the data is available, your app and its dependencies won’t know how to access it.
A virtual filesystem (VFS) lets you organize those bytes into files and folders, even if they aren’t real files on disk. You can open paths, read contents, list directories, or check metadata. The storage provider decides where the data actually lives, maybe in memory, inside an archive, as part of an executable, or in a real directory shown under a different path.
In Node.js, a VirtualFileSystem combines the storage provider with an API similar to fs. You can use it as a private file tree, or you can mount it to give the tree a path that node:fs operations and module loaders recognize. This way, code that reads templates or imports modules can keep using the same APIs as before.
This is what makes VFS useful. Tests can create in-memory fixtures without dealing with temporary directories. Packaged apps can read bundled configuration files using fs instead of a special asset API. Generated JavaScript can import nearby modules without writing them to disk first. You get to decide how to store and distribute files, without needing to update every dependency.
In our first VFS article, we talked about the original node:vfs proposal (PR #61478) and our userland implementation. Now, core support has landed, with a new mount model and support for ZIP archives, native addons, and shared-library loading using node:ffi.
The first version of node:vfs shipped in Node.js 26.4.0. Node.js 26.9.0 added module-loader integration, the ZIP provider, native-addon loading, and Single Executable Application support. Node.js 26.10.0 added VFS-backed FFI and startup flags for mounting and running from a VFS. The examples below use 26.10.0 as the baseline. VFS is still experimental and requires --experimental-vfs.
Start with the code
Save this as example.mjs:
import { create } from 'node:vfs'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
using app = create()
app.writeFileSync('/config.json', '{"name":"VFS"}')
app.writeFileSync('/greet.cjs', 'module.exports = name => `Hello, ${name}!`')
app.writeFileSync(
'/main.mjs',
`
import greet from './greet.cjs'
import { readFileSync } from 'node:fs'
const config = JSON.parse(
readFileSync(new URL('./config.json', import.meta.url), 'utf8')
)
export default greet(config.name)
`
)
const root = app.mount()
console.log(readFileSync(join(root, 'config.json'), 'utf8'))
console.log(await readFile(join(root, 'config.json'), 'utf8'))
const { default: greeting } = await import(`${app.mountPointURL}/main.mjs`)
console.log(greeting) // Hello, VFS!
Run it with:
node --experimental-vfs example.mjs
There are two namespaces in this setup. Before mounting, /config.json is just a path inside the private VFS instance. After mounting, regular node:fs calls can access that file through the returned root. The VFS instance’s own methods also expect mounted paths, so fill it with files before mounting, or use the returned root after.
The module inside the VFS uses a normal relative import and reads a file relative to import.meta.url. It doesn’t import node:vfs. Your application code shouldn’t need to know which provider stores its files.
Notice that we imported readFileSync before mounting. It still works. Core dispatches from inside the filesystem functions rather than replacing their public exports, so a dependency that captured a function reference earlier doesn't bypass the VFS.
mountPointURL is a file:URL string. Use it for dynamic imports instead of passing a platform-specific path, especially on Windows. The using declaration will unmount the instance when its scope ends.
Why you no longer choose the mount point
The original article showed this:
myVfs.mount('/virtual')
It also showed an overlay mounted at /, where virtual files took precedence, and missing files fell through to disk.
That is not the current core API. mount() takes no target and returns a path chosen by Node.js. Each instance gets its own reserved namespace, which cannot overlap another instance's mount or a real directory.
On POSIX systems, the current implementation uses paths shaped like /dev/null/vfs/0. That looks odd until you consider the reason: /dev/null is a device, not a directory. It cannot have real child files. Windows uses the corresponding null-device namespace. This is an implementation detail, not a path format to build into your application. Always use the value returned by mount()or the mountPoint and mountPointURL properties.
This change removes a whole set of questions from the resolution process. Does the virtual file take priority? Should we check the disk if it’s missing? What if one mount overlaps another?
A path under a mount belongs to that VFS. If the file isn’t there, it’s just missing; there’s no fallback to the disk for that path.
It also changes the testing story. You can give a library a virtual directory containing its fixtures, but you cannot use core VFS to silently replace /etc/config.json while leaving the rest of /etc alone. Code with hard-coded real paths needs a configurable path or a different testing technique.
We think this constraint is better. Mounting an application package shouldn’t change what an unrelated real path means elsewhere in the process.
The resolver stays the resolver
The tricky part of loading code from a virtual filesystem isn’t reading the JavaScript bytes. It’s figuring out all the questions that come before that step.
Which package.json applies? Is this .js file CommonJS or ESM? What does the package's exports field allow? Where should a bare package specifier look for node_modules?
The core integration sends the filesystem operations used by Node.js’s resolution algorithms to the right VFS. It doesn’t add a new package-resolution algorithm for virtual files. Package metadata is still handled by Node.js’s package JSON system.
The mount acts as a resolution root. Package-scope searches and the ordinary upward search through node_modules stop there. A package's module type won't accidentally come from a package.json above the virtual mount.
That needs a qualification: CommonJS still has its legacy global lookup paths, including NODE_PATH. And absolute specifiers can cross between virtual and real files. A module in a VFS can load real code, just as real code can load a virtual module. This is a resolution boundary, not an execution boundary.
Module identity works the way you'd expect. __filename and __dirname refer to mounted paths; import.meta.url refers to the corresponding file URL. Repeated loads are cached.
Unmounting clears loader-cache entries associated with that mount, including CommonJS modules, ESM load-cache entries, and package metadata. It leaves other mounts and real-filesystem modules alone. If you unmount, change the files, and remount, a new load can see the new code.
Unmounting doesn’t stop code that’s already running. References to functions you imported earlier still work. Timers keep running. Unmounting isn’t the same as shutting down the app, and changing a file while it’s mounted doesn’t trigger an automatic hot reload.
Providers describe storage, not isolation
The public API still has the same basic split: VirtualFileSystem supplies the fs-like interface, and a provider supplies storage operations.
The built-in public providers are:
| A writable in-memory tree, with symlinks and watching. This is the default. |
| Files in a real directory, mapped into the VFS namespace. |
| Entries in a |
VirtualProvider is the base class for custom implementations. It provides derived operations on top of primitives such as opening files and reading directory entries. Capabilities such as read-only storage, symlinks, and watching belong to the provider.
There are synchronous, callback, and promise-based APIs, plus streams and file handles. That familiar shape matters for compatibility, but it doesn't mean every backend can implement every filesystem operation. For example, the ZIP provider in 26.10.0 does not support recursive readdir, symlinks, or watching. Treat provider capabilities as part of your application's requirements.
RealFSProvider is now in core; the first article described it as a userland addition. SqliteProvider isn't a core export. The SEA provider is internal, used by Node.js when starting an executable with VFS-enabled assets, rather than a public SEAProvider constructor.
Create a compressed ZIP, then mount it
You can build the archive with Node.js itself. The experimental ZIP API in node:zlib creates ZIP entries and writes them into an archive. This is different from gzipping a file: a ZIP contains named entries that the VFS can expose as a directory tree.
Suppose your application has app/main.mjs, app/config.json, and app/package.json with {"main":"./main.mjs"} so the startup loader can find its entry point. Save this as pack.mjs:
import { createWriteStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { pipeline } from 'node:stream/promises'
import { ZipEntry, createZipArchive } from 'node:zlib'
const entries = []
for (const name of ['main.mjs', 'config.json', 'package.json']) {
const contents = await readFile(`./app/${name}`)
entries.push(await ZipEntry.create(name, contents, { method: 'deflate' }))
}
await pipeline(createZipArchive(entries), createWriteStream('./app.zip'))
Run node pack.mjs. The archive contains main.mjs, config.json, and package.json at its root, not under an extra app directory. Include every module, package manifest, and asset your application needs; this script isn't a dependency bundler.
Compression is selected per entry. 'deflate' is the default and a good choice when you want compatibility with other ZIP tools. Use 'store' to leave an entry uncompressed, which can make sense for already-compressed assets. 'zstd' is also supported, but check that any other tools consuming the archive support Zstandard-compressed ZIP entries. ZipEntry.create() keeps an entry uncompressed if compression wouldn't reduce its size.
This example reads each input file into memory. For large files, ZipEntry.createStream() can instead take a createReadStream() source and compress it as createZipArchive() writes the archive. On the reading side, ZipProvider handles decompression; application code just reads files normally.
Now mount the resulting archive and import its entry point:
import { create, ZipProvider } from 'node:vfs'
import { ZipFile } from 'node:zlib'
using archive = ZipFile.openSync('./app.zip')
using app = create(new ZipProvider(archive))
app.mount()
await import(`${app.mountPointURL}/main.mjs`)
You can read JavaScript and assets directly from the archive without extracting the app into a directory first. Native binaries use a different loading method, which I’ll explain next.
The archive is read-only by default. A ZipFile opened with { writable: true } can be modified; an in-memory ZipBuffer is writable. Writes through an open VFS file handle commit a new archive entry when that handle closes. A ZIP member isn't an ordinary disk file that can be edited in place.
Synchronous reads may include decompression, and synchronous writes may include compression. These operations block the event loop. Packaging your app in a ZIP is handy, but it doesn’t guarantee faster startup.
There are two lifetimes in this example. Disposing app unmounts it, and disposing archive closes the underlying archive. The declarations are set up so the VFS unmounts before the archive closes.
A correction: this is not a sandbox
In the first article, we described RealFSProvider as sandboxed filesystem access and suggested VFS for multi-tenant sandboxing. That wording was wrong.
RealFSProvider maps paths under a configured root and rejects paths and symlinks that resolve outside it. Those checks do not turn it into a security boundary. Mounting a VFS doesn't stop code from accessing other real paths, spawning processes, or using other capabilities available to the Node.js process.
The same goes for generated code. Importing an AI-generated module from memory is convenient, but it doesn’t make running it safe.
The VFS security documentation is explicit: this is not a sandbox, permission system, or access-control mechanism. Untrusted code needs an appropriate isolation boundary, such as an operating-system sandbox or a separately isolated process.
When Node.js's Permission Model is enabled, mounting is disabled unless you explicitly pass --allow-fs-vfs. That is permission to use the mounting mechanism, not a promise of tenant isolation.
Single executables can use ordinary file access
Single Executable Applications were one of the main reasons for this work. Embedding an asset is only part of the solution; the app still expects to read it using fs.
With "useVfs": true, Node.js mounts the bundled assets as a read-only VFS and runs the main script from inside it. Both CommonJS and ESM entry points are supported.
For example, a SEA configuration can include:
{
"main": "main.mjs",
"mainFormat": "module",
"output": "app.blob",
"useVfs": true,
"assets": {
"config.json": "config.json",
"greet.mjs": "greet.mjs"
}
}
The main module can then use:
import { readFileSync } from 'node:fs'
import { greet } from './greet.mjs'
const config = JSON.parse(readFileSync(new URL('./config.json', import.meta.url), 'utf8'))
console.log(greet(config.name))
The asset keys set the paths inside the virtual tree. You can include dependencies under node_modules, along with their package metadata. VFS doesn’t gather these dependencies for you; you still need to package all the files your app uses.
CommonJS code uses __dirname-relative paths instead. Either way, the application doesn't need a hard-coded mount point or a separate asset-access API.
There is a real trade-off: useVfs cannot currently be combined with useSnapshot or useCodeCache. If startup time is your priority, don't assume that preserving an unbundled module tree will beat a bundled application. Measure your workload.
Native addons need help from the operating system
The first article said core could support native addons where userland couldn't. That support has now landed.
You can require() a .node file stored in a mounted VFS. But the operating system's dynamic loader still cannot open a virtual path. Node.js reads the bytes and creates a private, self-cleaning image that the loader can use.
On Linux, it uses an anonymous in-memory file descriptor. Other platforms use temporary files. That’s why you shouldn’t claim that loading an app from a VFS “never touches disk” for every provider and platform.
Native binaries still have to match the target platform and ABI. VFS only changes how the bytes get to the loader, not the compatibility requirements.
Shared libraries through node:ffi
Native addons aren't the only way to call native code. Node.js's Foreign Function Interface, node:ffi, lets JavaScript open a shared library and call exported functions by describing their argument and return types. The library doesn't need to implement the Node.js addon API; it needs a compatible C ABI.
The VFS integration for FFI lets both ffi.dlopen() and new ffi.DynamicLibrary() open libraries from mounted paths. This support arrived in Node.js 26.10.0, after the .nodeaddon support in 26.9.0. The following example needs a 26.10.0 build with node:ffi available.
Suppose app.zip contains a native directory with a library named libmath.dylib, libmath.so, or libmath.dll, depending on the target platform. It exports int32_t add_i32(int32_t, int32_t). We can load it straight from the mounted archive:
import { create, ZipProvider } from 'node:vfs'
import { ZipFile } from 'node:zlib'
import { dlopen, suffix } from 'node:ffi'
import { join } from 'node:path'
using archive = ZipFile.openSync('./app.zip')
using app = create(new ZipProvider(archive))
const root = app.mount()
const { lib, functions } = dlopen(join(root, 'native', `libmath.${suffix}`), {
add_i32: { arguments: ['int32', 'int32'], return: 'int32' }
})
try {
console.log(functions.add_i32(20, 22)) // 42
console.log(lib.path) // The mounted virtual path
} finally {
lib.close()
}
This uses the same private-image method as native addons. Node.js reads the library’s bytes from the provider and makes them available to the OS’s dynamic loader. lib.path still shows the virtual path, not the temporary image’s location. Libraries opened from real paths still load directly.
This means a package can include its JavaScript, assets, and a native library together, without needing custom extraction code. You still need a binary for the right OS and architecture, correct function signatures, and any native dependencies the OS needs. VFS doesn’t make a shared library portable.
The library also has its own lifetime. Unmounting the VFS doesn't unload a library you've already opened; close the library when you're finished with it. In this example, finally closes it before the using declarations unmount the VFS and close the archive. Under the Permission Model, native calls also require --allow-ffi, separately from the VFS mounting permission.
Mount before the application starts
The examples so far need a bootstrap script to create and mount the VFS. Node.js 26.10.0 also includes startup flags contributed by Philipp Dunkel:
node --experimental-vfs --vfs-load=app.zip
This command works on Node.js 26.10.0 or later; the flags are not available in 26.9.0.
--vfs-load mounts the source and runs its entry point. It uses the mount's package.jsonmain field or index.js, much like running a directory. --vfs-mount mounts a source without selecting it as the entry point. Multiple mounts remain separate trees; they aren't an overlay or a merged dependency directory.
Directories use RealFSProvider. ZIP archives use ZipProvider, selected from their contents rather than their filename extension.
The same change adds registerProvider({ name, canHandle, create }). A module preloaded with --require or --importcan register another format before startup mounts are created. Registered providers are checked before the built-ins, newest registration first. This lets custom packaging formats participate without teaching core about every format.
What this means for the original proposal
The first article promised a one-line migration from @platformatic/vfs to node:vfs. Given how the core design evolved, that promise was too broad.
We've just released @platformatic/vfsv0.5.0 with compatibility for the new mount API: mount() takes no prefix and returns an isolated path, and mountPointURL provides its file URL. This lets code use the same mount pattern on Node.js 22+, but the userland shim still cannot provide core's ZIP provider, SEA integration, or native-addon loading.
If your code picks mount points, relies on overlays, or uses a provider that core doesn’t export, just swapping the import isn’t enough. Build your app around the returned mount path, use relative paths, and check which provider features you really need. The original process mount-event examples aren’t part of the current core API either.
The good news is that the useful part remains: regular application code can read files and resolve modules from different storage providers. The rule against shadowing real paths makes this behavior easier to understand.
James Snell co-authored the module-loader integration. Philipp Dunkel contributed the ZIP provider, native-addon loading, and startup flags. The landed changes also carry review from Paolo Insogna, Stephen Belanger, and other Node.js collaborators. This work became much more than the original holiday prototype.
Try the first example on Node.js 26.10.0 or a newer version with the right APIs. Then test a small, trusted module tree from your own app. If any supported filesystem operation or resolution works differently from disk, report it with a reproducible example.





