Open-source project
streamich/memfs avatar
streamich/memfs

memfs: a filesystem in memory that watches paths, not inodes

Node.js "fs" and browser "File System API" adapters and in-memory implementations 👍

2,107 stars152 forksTypeScriptApache-2.0

At a glance

What is it?
memfs implements Node's fs module and the browser File System API in memory, and converts between the two through adapter packages, with file watching split across four packages under a @jsonjoy.com scope. Its strength is a test suite that cannot touch your disk; its cost is deliberate divergence, since events fire inside the mutating call and one adapter direction emits no rename records at all.
Who is it for?
Adopt memfs when a test must read and write files without leaving anything on disk, or when browser code needs a synchronous fs surface over OPFS. Decline it when your logic depends on POSIX inode identity, on events arriving after the call that caused them, or on rename records seen through FileSystemObserver over an fs tree.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 24 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Why tests and bundlers want a filesystem inside memory

memfs exists for code that calls the file APIs of Node.js or of a browser and has no business writing to a real folder. It ships two in-memory implementations, one for the Node.js fs module API and one for the browser File System API, plus adapters that translate between the two interfaces in both directions. The root package description compresses the goal into one line, an in-memory file system with Node's fs API, and the keyword list beside it names the audience: test, mock, mount, virtual.

The use cases cluster in two places. A unit test can point a config loader, a tar writer or a git client at a tree that vanishes when the process exits, which removes tmp directory cleanup from the suite and removes write permission problems from a sandboxed run. A browser bundle can call synchronous Node style fs calls against OPFS or against a real folder the user picks, which is what the git and zip file demos exercise. Monkey patching of fs and `require` is not part of this repository, and the README points at a separate package, fs-monkey, for that.

npm i memfs and where the first real example lives

The only install line the project gives is the standard npm one:

bash
npm i memfs

After that the README prints no first snippet inline, and it documents neither rollback nor an upgrade procedure. What it offers instead is a list of eight resources, and two of them decide your next hour: `./docs/node/index.md` for the in-memory fs API and `./docs/fsa/fsa.md` for the browser side. Four more cover the adapters in each direction, the `snapshot` directory utility at `./docs/snapshot/index.md`, and the `print` utility that draws a directory tree to the terminal.

Runnable code sits in the demo tree rather than in the README. `demo/git/README.md` runs git on the in-memory file system, `demo/git-fsa/README.md` runs git in a browser writing to a real folder, and `demo/git-opfs/README.md` aims the same client at OPFS. Expect to pick a side before you write anything: the Node fs shape, the browser File System Access shape, or one of the four adapter packages.

Four scoped packages sit behind one bare package name

The name you install and the names that carry the features are not the same name. `memfs` is the entry point in the install line, while every watcher lives in a scoped package: `@jsonjoy.com/fs-node` for the Node side, `@jsonjoy.com/fs-fsa` for the browser File System Access side, and `@jsonjoy.com/fs-fsa-to-node` with `@jsonjoy.com/fs-node-to-fsa` for the two adapter directions. The root of the tree is a private package called `memfs-monorepo` at version 4.80.0, and the top level holds `packages/`, `docs/`, `scripts/`, `tsconfig.json`, `tslint.json` and `yarn.lock`, which is the shape of a yarn workspaces monorepo. That same root declares `"license": "Apache-2.0"`, with the Apache 2.0 text in the LICENSE file beside it.

Builds are driven per workspace rather than per directory:

json
"build": "yarn workspaces foreach -Apt run build",
"typecheck": "yarn workspaces foreach -Apt run typecheck",
"test": "jest --maxWorkers 2",

So one clone builds every library in the tree, and the root test script runs jest with a two worker cap.

The watch table splits by which side you stand on

Watching carries the most moving parts here, and the README spends a table on it because the answer depends on which side of the adapter you are standing. In `@jsonjoy.com/fs-node`, `fs.watch` takes the full option set, with `recursive`, `encoding`, `signal`, `throwIfNoEntry` and `ignore`, and the handle emits `'error'` and `'close'` events and answers to `ref()` and `unref()`. `fs.watchFile` and `fs.unwatchFile` poll, accept `bigint` and reproduce Node's reappearance semantics. `fs.promises.watch` returns an async iterator with `maxQueue`, an `overflow` signal and an `AbortError` on abort.

The browser side divides again. `FileSystemObserver` in `@jsonjoy.com/fs-fsa` is described as a deterministic OPFS profile with precise records, real `"moved"` records and microtask batching. `@jsonjoy.com/fs-fsa-to-node` puts `fs.watch` and `fs.watchFile` over a real File System Access directory, working over OPFS in Chrome 133+ through the native observer. `@jsonjoy.com/fs-node-to-fsa` runs the other way and is labelled a best-effort profile, with renames stat-classified and no `"moved"` records.

Events fire inside the call that changed the file

The README groups its watcher differences under one sentence: the in-memory backend is deterministic, so platform unreliability is not emulated. Four consequences follow, and they are the ones that unsettle tests written against a real disk. Exactly one event arrives per logical operation, so there are no duplicate platform events to filter. `filename` is never `null`. The Node style `FSWatcher` delivers synchronously, inside the call that performed the mutation, where real Node defers delivery to the event loop.

The inode rule cuts deeper. Paths are watched semantically rather than by inode: after a delete and recreate of one path, memfs reports events for the recreated entry, while a real POSIX watcher stays on the dead inode and goes quiet. An assertion that unlinks a file, writes a new one at the same path and waits for a change event therefore exercises a different sequence in memory than on disk. Code that counts events, or that expects a silent gap between operations, deserves a run against both a real directory and the memory tree.

One adapter direction emits no rename records at all

Renames are where the two adapters stop being equivalent, and the difference is stated rather than buried. On the File System Access side, `@jsonjoy.com/fs-fsa` produces real `"moved"` records against OPFS. On the other side, `@jsonjoy.com/fs-node-to-fsa` classifies renames by comparing stat results and emits no `"moved"` record, which the README calls a best-effort profile. Browser code that branches on the record type of a change needs a fallback for the fs-tree case, and a test written against OPFS does not transfer unchanged to a memory tree observed through FileSystemObserver.

Two timing models share one library as well: `FSWatcher` fires inside the mutation, while `FileSystemObserver` batches per microtask as the spec requires. The bridge back to real storage has a floor too, because watching a real File System Access directory through `fs.watch` needs OPFS and the native observer in Chrome 133+ or newer. The Node shaped watcher surface in a browser is not reachable on an older Chrome even though the adapter package itself installs.

Printing and snapshotting a tree that vanishes with the process

A tree held in memory disappears with the process, which makes a failing test hard to read. Two utilities in `./docs/` address exactly that: a directory `snapshot` helper at `./docs/snapshot/index.md`, and a `print` routine that draws the directory tree to the terminal. Inside a test run either one turns an invisible tree into lines in the CI log, and a diff between two snapshots shows what a function actually wrote rather than what it claimed.

The published reference is automated the same way. The code reference at streamich.github.io/memfs and the coverage report at streamich.github.io/memfs/coverage/lcov-report/ both come out of one script, paired with a clean step that clears the same output:

json
"clean": "rimraf lib coverage typedocs gh-pages && yarn workspaces foreach -Apt run clean",

and a publish step that fills the directory:

json
"build:pages": "npx [email protected] gh-pages && mkdir -p gh-pages && cp -r typedocs/* gh-pages && cp -r coverage gh-pages/coverage"

One line pins rimraf at 5.0.5 through npx while the other calls rimraf unpinned, so the two scripts managing that directory disagree about the tool they run.

Two root scripts still point at a folder the tree no longer has

This is where a close read of the tree pays off. Two scripts in the root `package.json` still address a directory that the current layout does not contain:

json
"tslint": "tslint \"src/**/*.ts\" -t verbose",
"watch": "watch \"npm run build\" ./src",

Both glob or rebuild `./src`, yet the top level holds `packages/` and `docs/` and no `src/`, with a `tslint.json` parked beside `tsconfig.json` while build, typecheck, clean and test all run through other tools. A new contributor who copies the `tslint` invocation gets nothing back, and the leftover config file makes the tree look as though a single root package once held the source.

The code itself is moving. The last push was on 2026-09-29, and the three most recent tags are v4.80.0 from 2026-09-29, v4.79.0 from 2026-09-22 and v4.78.1 from 2026-09-17, twelve days apart at the widest. The watcher table has six rows and two rename profiles to keep in step, and a tag on its own does not say which of them moved.

Editorial conclusion

Adopt memfs when a test must read and write files without leaving anything on disk, or when browser code needs a synchronous fs surface over OPFS. Decline it when your logic depends on POSIX inode identity, on events arriving after the call that caused them, or on rename records seen through FileSystemObserver over an fs tree. Verify first by running your own suite against @jsonjoy.com/fs-node, counting every assertion that leans on the synchronous event timing, and reaching for the print utility in ./docs/print/index.md when a memory tree has to be read after the process exits.

Frequently asked questions

Is memfs a drop-in replacement for the Node.js fs module in tests?

It implements the Node.js fs module API in memory, so imports swap over, but the watchers diverge on purpose: events arrive synchronously inside the mutating call and each logical operation emits exactly one event, with filename never null. Assertions that depend on real event timing need a second run against a real directory.

Does memfs need a real folder or any disk space at runtime?

The in-memory implementations need neither, since the whole tree is held by the process. Real folders enter only through the adapters, and watching a real File System Access directory with fs.watch requires OPFS in Chrome 133+ or newer through the native observer.

Why do the watcher packages use the @jsonjoy.com scope instead of the memfs name?

The README installs the bare memfs name, while its watch table assigns every watcher to a scoped package such as @jsonjoy.com/fs-node and @jsonjoy.com/fs-fsa. The repository root is a separate private package, memfs-monorepo at version 4.80.0, that holds the workspaces those names come from.

How does memfs report a file rename?

FileSystemObserver in @jsonjoy.com/fs-fsa emits real "moved" records against OPFS, while @jsonjoy.com/fs-node-to-fsa classifies renames from stat results and emits no "moved" record at all, a profile the README labels best-effort. Code that branches on the record type needs a fallback for the fs-tree direction.

Does memfs keep watching a path after its file is deleted and recreated?

Paths are watched semantically rather than by inode, so after a delete and recreate memfs reports events for the recreated entry. A real POSIX watcher keeps watching the dead inode and goes quiet instead, which makes that particular assertion non-transferable between the two.

Official sources

  1. License: Apache-2.0
  2. Project website
  3. README
  4. Releases
  5. streamich/memfs on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/streamich-memfs.svg)](https://hysenlabs.com/projects/streamich-memfs)

Was this useful?