mrkeyoor.com_
Thu 06 Aug 05:55 UTC
npmTestingupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The fs, vol, Volume, and createFsFromVolume surface has been stable across the whole 4.x line, but 4.x quietly moved the implementation into @jsonjoy.com/fs-* packages and a 5.0.0-next prerelease is already published on the next tag
Docs4/5Separate docs for the Node API, the browser File System Access adapters, snapshots, and directory printing, plus an explicit page on the relative-path gotcha and a table of intentional divergences from real watchers; test-runner integration is left to you to work out
Maintenance5/5Pushed the same day as the 4.68.0 release with only 32 open issues, releasing minors steadily; it is effectively one maintainer's project, which is the standing risk
Ecosystem5/5Roughly 39M downloads a week, used inside build tools and browser IDEs, with companion packages (unionfs, fs-monkey, linkfs, spyfs) from the same author for layering and patching

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
Skip it if

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 disk

Layer 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(); // true

Symlinks 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

PackageRegistryPick it when
mock-fsnpmYou want to patch the real fs module globally for the duration of a test rather than import a different fs object
unionfsnpmYour code must read real files from disk while writes and fixtures go to memory, layered into one fs object
tmp-promisenpmYou would rather test against the real filesystem in an auto-cleaned temp directory and keep behavior identical to production