memfs
memfs is an in-memory implementation of Node's fs module. You import fs from memfs instead of node:fs and every write, read, mkdir, symlink, and watch happens against a data structure in RAM that models inodes, hard links, and symlinks. Volumes can be built from a plain JSON object and dumped back to one, which makes assertions about what your code wrote a single deepEqual. It runs in browsers as well as Node, and the same package ships adapters between the fs API and the browser File System Access API, so code written against fs can drive OPFS or a user-picked directory.
The best in-memory fs for JavaScript, and the fastest way to make file-writing code testable without temp-directory bookkeeping. Remember what it is: a faithful but deterministic model, so keep at least one test running against a real directory for anything that depends on how a filesystem actually behaves.
Use it if
- You are testing code that writes files and want each test to start from a known tree built with vol.fromJSON and asserted with vol.toJSON
- Your test suite is slow because of real disk IO, or flaky because tests share a temp directory
- You need to run fs-dependent code in the browser (a bundler, a playground, a git implementation) where node:fs does not exist
- You want several isolated filesystems at once, which Volume.fromJSON and memfs() both give you without any global patching
- Your test only needs a scratch directory: fs.mkdtemp under os.tmpdir() is two lines, exercises the real kernel, and cannot drift from it
- You depend on real filesystem behavior: memfs deliberately emits exactly one event per operation, never a null filename, and watches paths semantically rather than by inode, so watcher code that passes here can still break on Linux or macOS
- Your code path reaches the disk through native bindings or a child process (better-sqlite3, sharp, esbuild, anything spawning a CLI): those bypass any JavaScript fs object entirely
- You count dependencies: 4.68 installs 14 packages, nine of them @jsonjoy.com/fs-* subpackages pinned to the exact same version, which is a much bigger tree than memfs used to be
- You need complete fs coverage: some fs APIs are still unimplemented (the project tracks the gap in a dedicated issue), and a 5.0 prerelease already sits on the next tag, so a breaking major is queued
Setup reality
npm i memfs installs a CommonJS build with bundled type definitions, no native step, and no peer dependencies. Two things bite immediately. Relative paths resolve against the real process.cwd(), which almost never exists inside your volume, so either use absolute paths, call process.chdir('/'), or pass a cwd to memfs(). And swapping it in for the real module is not automatic: Jest needs a __mocks__/fs.js and a matching __mocks__/fs/promises.js plus explicit jest.mock() calls, while Vitest needs a vi.mock factory covering both the default and named exports, and each of node:fs, fs, node:fs/promises, and fs/promises is a separate module identifier you have to mock. Shared state is the other trap: the exported vol is a module-level singleton, so tests leak into each other unless something calls vol.reset().
Patterns
Use it as a drop-in fsbasic-read-write
import { fs } from 'memfs';
fs.mkdirSync('/app/src', { recursive: true });
fs.writeFileSync('/app/src/index.js', 'export default 1;');
fs.readFileSync('/app/src/index.js', 'utf8');
// 'export default 1;'The exported fs is bound to a single shared volume for the whole process. Stick to absolute paths: a relative path is resolved against the real process.cwd(), which does not exist in the volume, so you get ENOENT on a directory you just created.
Build a fixture tree from JSONvolume-from-json
import { fs, vol } from 'memfs';
vol.fromJSON({
'./README.md': '# hi',
'./src/index.js': 'export default 1;',
'./empty.txt': '',
'./logs': null, // null means an empty directory
}, '/app');
fs.readFileSync('/app/src/index.js', 'utf8');fromJSON adds to whatever is already there rather than replacing it; call vol.reset() first if you want a clean tree. Intermediate directories in the keys are created for you.
Assert on what your code wroteassert-written-files
import { vol } from 'memfs';
await generateProject('/out');
expect(vol.toJSON('/out')).toEqual({
'/out/package.json': '{\n "name": "demo"\n}',
'/out/src/index.ts': 'export {};',
});toJSON returns text for anything that decodes as UTF-8 and a Buffer otherwise, so binary output compares as Buffers. Pass a path to scope the dump; with no argument you get every file in the volume, including fixtures from other tests.
Stop tests leaking into each otherreset-between-tests
import { vol } from 'memfs';
beforeEach(() => {
vol.reset();
vol.fromJSON({ '/app/config.json': '{}' });
});The default vol is module-level state shared by every test file in the same worker. Without reset, a file written in one test is visible in the next, and the failure shows up in whichever test happens to run second.
Create independent filesystemsisolated-volumes
import { memfs, Volume, createFsFromVolume } from 'memfs';
const { fs, vol } = memfs({ '/foo': 'bar' });
fs.readFileSync('/foo', 'utf8'); // 'bar'
// or build the volume first and wrap it
const other = Volume.fromJSON({ '/foo': 'different' });
const otherFs = createFsFromVolume(other);memfs() is the version to reach for in parallel tests: nothing is shared, so no reset is needed. Volume instances alone lack the fs constants (vol.F_OK is undefined) because those live on the wrapper createFsFromVolume builds.
Make relative paths resolve somewhere realcustom-cwd
import { memfs } from 'memfs';
const { fs } = memfs({ './package.json': '{}' }, '/project');
fs.readFileSync('./package.json', 'utf8'); // resolves under /project
// with the shared volume instead:
// vol.mkdirSync(process.cwd(), { recursive: true });
// or process.chdir('/');This is the single most common memfs bug report. The default cwd is '/', and code under test that calls path.resolve() against the real working directory will look for files in a directory your volume never created.
Use the promise APIpromises-api
import { fs } from 'memfs';
const { promises: fsp } = fs;
await fsp.mkdir('/data', { recursive: true });
await fsp.writeFile('/data/out.json', JSON.stringify(rows));
const entries = await fsp.readdir('/data', { withFileTypes: true });fs.promises mirrors node:fs/promises, but it is a separate module identifier when you mock: mocking 'fs' does nothing for code that imported 'node:fs/promises'.
Replace node:fs in Jestjest-mock
// __mocks__/fs.js
const { fs } = require('memfs');
module.exports = fs;
// __mocks__/fs/promises.js
const { fs } = require('memfs');
module.exports = fs.promises;
// some.test.js
jest.mock('fs');
jest.mock('fs/promises');
const { vol } = require('memfs');
beforeEach(() => vol.reset());Both mock files are required; plenty of libraries import only fs/promises. Modules that captured a reference to fs at import time before jest.mock ran keep the real one, so mock before importing the code under test.
Replace node:fs in Vitestvitest-mock
import { vi, beforeEach } from 'vitest';
vi.mock('node:fs', async () => {
const memfs = await vi.importActual('memfs');
return { default: memfs.fs, ...memfs.fs };
});
vi.mock('node:fs/promises', async () => {
const memfs = await vi.importActual('memfs');
return { default: memfs.fs.promises, ...memfs.fs.promises };
});
const { vol } = await import('memfs');
beforeEach(() => vol.reset());You have to return both a default export and the named exports, or import { readFileSync } from 'node:fs' resolves to undefined. Mock 'fs' as well if your dependencies use the unprefixed specifier.
Read and write with streamsstreams
import { fs } from 'memfs';
import { pipeline } from 'node:stream/promises';
fs.writeFileSync('/in.txt', 'a\nb\nc\n');
await pipeline(
fs.createReadStream('/in.txt'),
transformUpperCase(),
fs.createWriteStream('/out.txt'),
);
fs.readFileSync('/out.txt', 'utf8');createReadStream and createWriteStream are real Node streams over in-memory buffers, so backpressure exists but never blocks on disk. A test that streams a large file allocates all of it in the test process heap.
Overlay memory on top of the real diskunion-with-real-fs
import * as realFs from 'node:fs';
import { vol } from 'memfs';
import { ufs } from 'unionfs';
vol.fromJSON({ '/app/config.json': '{"mocked":true}' });
ufs.use(realFs).use(vol);
ufs.readFileSync('/app/config.json', 'utf8'); // from memory
ufs.readFileSync('/etc/hostname', 'utf8'); // from diskLayer order decides who wins, and later .use() calls take priority. Writes go to the layer that resolves the path, so it is easy to write to the real disk by accident when the memory layer has no matching directory.
Model symlinks and hard linkssymlinks-and-links
import { fs, vol } from 'memfs';
vol.fromJSON({ '/pkg/index.js': 'module.exports = 1;' });
fs.symlinkSync('/pkg', '/node_modules/pkg');
fs.linkSync('/pkg/index.js', '/pkg/main.js');
fs.realpathSync('/node_modules/pkg/index.js'); // '/pkg/index.js'
fs.lstatSync('/node_modules/pkg').isSymbolicLink(); // trueSymlinks and hard links are modeled properly, which is why memfs works for node_modules resolution tests. toJSON follows links, so a linked file appears once per path and looks duplicated in your assertion.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| mock-fs | npm | You want to patch the real fs module globally for the duration of a test rather than import a different fs object |
| unionfs | npm | Your code must read real files from disk while writes and fixtures go to memory, layered into one fs object |
| tmp-promise | npm | You would rather test against the real filesystem in an auto-cleaned temp directory and keep behavior identical to production |