Skip to main content

Command Palette

Search for a command to run...

Why Node.js needs a virtual file system

Published
10 min read
Why Node.js needs a virtual file system

Node.js has always been about I/O. Streams, buffers, sockets, files. The runtime was built from day one to move data between the network and the filesystem as fast as possible. But there’s a gap that has bugged me for years: you can’t virtualize the filesystem.

You can’t import or require() a module that only exists in memory. You can’t bundle assets into a single executable without patching half the standard library. You can’t sandbox file access for a tenant without reinventing fs from scratch.

That changes now. We’re announcing@platformatic/vfs, a userland Virtual File System for Node.js, and the upstream node:vfs module landing in Node.js core.

The problem

Here’s what it looks like in practice when Node.js doesn’t have a VFS:

  1. Bundle a full application into a Single Executable. You need to ship configuration files, templates, and static assets alongside your code. This often means bolting on 20 to 40 MB of extra boilerplate just to handle asset access at runtime. Node.js SEAs can embed a single blob, but your application code still calls fs.readFileSync() expecting real paths, so you end up duplicating files or injecting glue code that bloats your binary.

  2. Run tests without touching the disk. You want an isolated, in-memory filesystem so tests don’t leave artifacts and don’t collide in CI. Today, you mock fs with tools like memfs, but those mocks don’t integrate with import or require().

  3. Sandbox a tenant’s file access. In a multi-tenant platform, you need to confine each tenant to a directory without them escaping via ../. You end up writing path validation logic that’s fragile and easy to get wrong.

  4. Load code generated at runtime. AI agents, plugin systems, and code generation pipelines produce JavaScript that needs to be imported. Today, that means writing to a temp file and hoping cleanup happens.

All four require the same primitive: a virtual filesystem that hooks into node:fs and Node.js module loading. The ecosystem has built approximations like memfs, unionfs, mock-fs, but they all share the same limitation: they patch fs but not the module resolver. Code that calls import('./config.json') bypasses them entirely.

The original issue requesting VFS hooks for SEAs, opened by Daniel Lando, captured this well. The FS hooks proposal from the Single Executable working group documented years of requirements. People knew what they wanted. Nobody had built it yet.

node:vfs in Node.js core

I started working on a VFS implementation over Christmas 2025. What began as a holiday experiment became PR #61478: a node:vfs module for Node.js, with almost 14,000 lines of code across 66 files.

Let me be honest: a PR that size would normally take months of full-time work. This one happened because I built it with Claude Code. I pointed the AI at the tedious parts, the stuff that makes a 14k-line PR possible but no human wants to hand-write: implementing every fs method variant (sync, callback, promises), wiring up test coverage, and generating docs. I focused on the architecture, the API design, and reviewing every line. Without AI, this would not have been a holiday side project. It just wouldn’t have happened.

Here’s what it looks like:

import vfs from 'node:vfs'
import fs from 'node:fs'

const myVfs = vfs.create()

myVfs.mkdirSync('/app')
myVfs.writeFileSync('/app/config.json', '{"debug": true}')
myVfs.writeFileSync('/app/module.mjs', 'export default "hello from VFS"')

myVfs.mount('/virtual')

// Standard fs works
const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8'))

// import works, and so does require()
const mod = await import('/virtual/app/module.mjs')
console.log(mod.default) // "hello from VFS"

myVfs.unmount()

This is not a mock. When you call myVfs.mount('/virtual'), the VFS hooks into the actual fs module and the module resolver. Any code in the process, yours or your dependencies, that reads from paths under /virtual gets content from the VFS. Third-party libraries don’t need to know about it. express.static('/virtual/public') just works.

How it’s structured

The VFS has a provider layer and a mount layer.

Providers are the storage backends. MemoryProvider is the default: in-memory, fast, gone when the process exits. SEAProvider gives read-only access to assets embedded in Single Executable Applications. VirtualProvider is a base class you can extend for custom backends (database, network, whatever you need).

Mounting is how the VFS becomes visible to the rest of the process. myVfs.mount('/virtual') makes VFS content accessible under that path prefix. The process object emits vfs-mount and vfs-unmount events so you can track what’s going on:

process.on('vfs-mount', (info) => {
 console.log(`VFS mounted at \({info.mountPoint}, overlay: \){info.overlay}, readonly: ${info.readonly}`)
})

There’s also an overlay mode for when you want to intercept specific files without hiding the real filesystem:

const myVfs = vfs.create({ overlay: true })
myVfs.writeFileSync('/etc/config.json', '{"mocked": true}')
myVfs.mount('/')

// /etc/config.json comes from VFS
// /etc/hostname comes from the real filesystem

Only the paths that exist in the VFS are intercepted. Everything else goes to the real filesystem. For testing, this is ideal: you can override a few files and leave the rest untouched.

The fs API

The VFS isn’t a subset of fs. It covers synchronous, callback, and promise-based APIs for reading, writing, directories, symlinks, file descriptors, streams, watching, and glob. VirtualStats matches fs.Stats. Error codes match what Node.js returns (ENOENT, ENOTDIR, EISDIR, EEXIST). Code that works with the real filesystem should work with the VFS.

Why VFS needs to live in core Node.js

@platformatic/vfs proves the API works, but it also proves why a userland implementation will always be a compromise. Here’s what you run into when you try to build this outside of Node.js:

Module resolution is duplicated. The userland package contains 960+ lines of module resolution logic: walking node_modules trees, parsing package.json exports fields, trying index files, and resolving conditional exports. All of this already exists inside Node.js.

In core, the VFS hooks directly into the existing resolver. In userland, we re-implement it and hope we got every edge case right.

Private APIs. On Node.js versions before 23.5, there’s no public API to hook module resolution. The userland package patches Module._resolveFilename and Module._extensions, both private internals with no stability guarantees. A Node.js minor release could break them.

In core, the VFS is part of the resolver, not a patch on top of it.

Global fs patching is fragile. The userland package replaces fs.readFileSync, fs.statSync, and other core functions. If any code captures a reference to fs.readFileSync before the VFS mounts, that reference bypasses the VFS entirely.

In core, the interception happens below the public API surface, so captured references still work.

Native modules don’t work. dlopen() needs a real file path.

A userland VFS can’t teach the native module loader to read .node files from memory. Core can.

Module cache cleanup is impossible. When you unmount a VFS, modules that were require()'d from it stay in require.cache.

The userland package has no way to distinguish VFS-loaded modules from real ones, so it can’t clean them up. Core can track which modules came from which VFS and invalidate them on unmount.

None of these issues are bugs in the userland package. They’re just fundamental limits of what’s possible outside the runtime. The userland package is a bridge. Use it now, and switch to node:vfs when it becomes available.

Where the PR stands

The PR is open and in active review. The feature will be released as experimental.

Joyee Cheung from Igalia has been the most thorough reviewer. She pushed hard on the security model around mount(), flagged that internalModuleStat shouldn’t be exposed as public API, and pointed to the VFS requirements document that the Single Executable working group collected over four years. Her feedback made the implementation significantly better.

James Snell and Paolo Insogna approved the PR. Stephen Belanger raised important questions about the security implications of global mount() hijacking and suggested integrating with the permission model. Ethan Arrowood did a thorough review of the docs and tests. Aviv Keller caught places where code could be simplified with node:path. Richard Lau and Tierney Cyren provided feedback on documentation structure.

Thanks to everyone involved. Reviewing a 14,000-line PR is a big job, and they all put in the effort.

@platformatic/vfs: use it today

We didn’t want to wait for the core PR to be merged.

When Malte Ubl, CTO of Vercel, saw the PR, he tweeted:

“ I saw @matteocollina Virtual File System PR for Node.js, and I’m super excited about it! And so I was wondering if it could be back-ported in user-land. Looks pretty good. May publish it to npm”

We had the same idea, and so did the Vercel team, who published node-vfs-polyfill. When two teams independently extract the same API into userland, it’s a good sign that the design is solid.

Our version is@platformatic/vfs, and it works on Node.js 22 and above.

npm install @platformatic/vfs

The API matches what’s proposed for node:vfs:

import { create, MemoryProvider, SqliteProvider, RealFSProvider } from '@platformatic/vfs'

const vfs = create()
vfs.writeFileSync('/index.mjs', 'export const version = "1.0.0"')
vfs.mount('/app')

const mod = await import('/app/index.mjs')
console.log(mod.version) // "1.0.0"

When node:vfs ships in core, migrating is a one-line change: swap '@platformatic/vfs' for 'node:vfs' in your import.

Extra providers

The userland package ships two providers that aren’t in the core PR. SqliteProvider gives you a persistent VFS backed by node:sqlite. Files survive process restarts:

import { create, SqliteProvider } from '@platformatic/vfs'

const disk = new SqliteProvider('/tmp/myfs.db')
const vfs = create(disk)

vfs.writeFileSync('/config.json', '{"saved": true}')
disk.close()

// Later, in another process:
const disk2 = new SqliteProvider('/tmp/myfs.db')
const vfs2 = create(disk2)
console.log(vfs2.readFileSync('/config.json', 'utf8')) // '{"saved": true}'

This is helpful for caching compiled assets or keeping generated code across deployments.

RealFSProvider is sandboxed real filesystem access. It maps VFS paths to a real directory and prevents path traversal:

import { create, RealFSProvider } from '@platformatic/vfs'

const provider = new RealFSProvider('/tmp/sandbox')
const vfs = create(provider)

vfs.writeFileSync('/file.txt', 'sandboxed') // Writes to /tmp/sandbox/file.txt
vfs.readFileSync('/../../../etc/passwd') // Throws, can't escape the sandbox

Use cases

Single Executable Applications

Node.js SEAs can embed assets, but accessing them has always been tricky. With VFS, SEA assets are automatically mounted and can be accessed through standard fs calls, import, and require(). Your application code doesn’t need to know it’s running as an SEA.

Testing

You can create an isolated filesystem per test. No temp directories to clean up, no collisions between parallel test runs:

import { create } from '@platformatic/vfs'
import { test } from 'node:test'

test('reads config from virtual filesystem', () => {
 using vfs = create()
 vfs.writeFileSync('/config.json', '{"env": "test"}')
 vfs.mount('/app')

 // Your application code reads /app/config.json through standard fs
 // No disk I/O, no cleanup needed
 // The `using` statement automatically unmounts when the block exits
})

AI agents and code generation

AI agents generate code that needs to run. Writing to temp files is slow, creates cleanup problems, and increases security risks. With VFS, generated code stays in memory and can be loaded with import:

import { create } from '@platformatic/vfs'

const vfs = create()
vfs.writeFileSync('/handler.mjs', agentGeneratedCode)
vfs.mount('/generated')

const { default: handler } = await import('/generated/handler.mjs')
await handler(request)

What’s next

Both node:vfs and @platformatic/vfs are experimental. The test coverage is solid, but a virtual filesystem that hooks into module loading and node:fs has a huge surface area. There will be bugs. Edge cases we haven’t hit. Interactions with third-party code we didn’t anticipate.

If you hit something, please report it. For the userland package, open an issue on platformatic/vfs. For the core module, comment on the PR or open an issue on nodejs/node. Every bug report helps.

Once node:vfs lands in core, we’ll keep @platformatic/vfs in sync with any API changes and eventually deprecate it in favour of the built-in module.

In the meantime, try it out and let us know what you build.


node:vfs PR by Matteo Collina.

Fixes issue #60021 by Daniel Lando.

@platformatic/vfs is now on npm.