Performance
You don't need this page to use unfs.
The defaults are correct and safe. Everything below is opt-in, and each one trades a little ceremony, or a little trust, for a large speedup on the workload that needs it.
- Writing many files at once — collapse a bulk populate into one snapshot.
- Working with one file repeatedly — for logs, streams, and big reads.
- Caching decoded directories — for anything read-heavy.
- Skipping read verification — for stores only you can reach.
#Writing many files at once
Writing files one at a time gets slow fast. Every write is a snapshot, so adding 1,000 files to a directory rewrites that directory 1,000 times, and each rewrite costs more as the directory grows.
Wrap the writes in fs.batch and they commit together, as a single snapshot:
await fs.batch(async (tx) => {
for (const [path, bytes] of files) await tx.writeFile(path, bytes);
});Write through the tx handle your callback receives, not the outer fs. Only tx can see the batch's own writes.
The ref doesn't move until the batch commits, so other readers keep seeing the tree as it was. If the callback throws, nothing is committed.
For a bulk populate this is the difference between seconds and milliseconds, and the gap widens as you add files rather than staying constant. A batch of a single write is very slightly slower than a plain one, which is why it isn't the default.
createFsSync has the same thing, called batchSync.
#Working with one file repeatedly
open(path, flags?, mode?) hands you a FileHandle. flags defaults to "r" for reading, matching node. Pass a write flag to get a write handle instead.
#Appending in a loop
appendFile re-reads the whole file and re-chunks it on every call, so appending in a loop is quadratic. A write handle keeps one chunk writer open instead, so appending N bytes costs O(N) however many write calls it took:
const handle = await fs.open("/app.log", "w"); // "a" to append to what's already there
for (const line of lines) await handle.write(line + "\n");
await handle.sync(); // commit what's written so far, then keep going
await handle.close(); // finalize and commit; `await using handle = …` also worksThink of it as batch for a single file, with one commit at close.
Nothing you write is visible until sync() or close() commits it. The write flags are "a" and "ax" (append, starting from the file's current bytes) and "w" and "wx" (start empty). The x variants fail with EEXIST if the file already exists.
#Reading a big file
A read handle opens an immutable snapshot, which content addressing makes strictly better than the POSIX equivalent. The handle captures the file when you open it, following symlinks the way readFile does, and reads from that snapshot forever. Later overwrites, renames, unlinks, and garbage collection can't affect it, and torn reads aren't possible.
const handle = await fs.open("/data.bin"); // "r" is the default
const buf = new Uint8Array(4096);
const { bytesRead } = await handle.read(buf); // node's read(buffer, offset?, length?, position?)
const all = await handle.readFile(); // the whole snapshot; takes an encoding like fs.readFile
for await (const chunk of handle.readableWebStream()) {
// pulled one window at a time, never buffered whole
}
await handle.close(); // also `await using handle = …`read follows node's signatures, object forms included. Leave position out (or pass null) to read from the handle's cursor and advance it; pass a number to read from there without moving the cursor. Reading past the end gives you bytesRead: 0.
readableWebStream is a standard ReadableStream<Uint8Array>. It reads one bounded window per pull, so cancelling early doesn't drag in the rest of the file. stat() reports on the snapshot.
Opening a directory gives you EISDIR and a missing path gives you ENOENT. The read/write combination flags ("r+", "a+", "w+") aren't supported.
#Caching decoded directories
Resolving /a/b/c.ts reads and decodes /a and /a/b on the way, then does it all again for the next path underneath them. Attach a cache and those decoded directories get reused:
import { lruNodeCache } from "unfs";
const store = await initStore(kv, { cache: lruNodeCache() });This makes a dramatic difference to read-heavy work: small-file reads, stat, and readdir all speed up by more than an order of magnitude. Writes get a smaller boost, since rebuilding a path re-reads the directories it rewrites. Large files see nothing, because their cost is hashing content rather than decoding directories.
A cached entry can never go stale, because blocks are addressed by their content. There's no invalidation to think about, and one cache can even be shared across stores.
Tune the bound with lruNodeCache({ maxEntries, maxBytes }), which defaults to 4096 entries and 8 MiB. Treat both as approximate: it's a generational LRU, so up to twice either bound can be resident.
Caution
Never modify a Node you read back. With a cache attached, it's shared with every later reader. Nothing in the library does this, and the fs API never hands you one.
#Skipping read verification
Every block that comes back from the store is re-hashed and checked against the reference that asked for it, so corruption or tampering underneath unfs surfaces as a MALFORMED error rather than bad data.
If nothing but your own process can reach the store, that check is redundant. Turn it off:
const store = await initStore(kv, { trusted: true }); // openStore takes it tooThis is a local read policy, not a property of the stored bytes. It isn't recorded anywhere, so one process can open a store trusted while another verifies it, and blocks are hashed on write either way.
Large-file reads get much faster, since reading one is almost entirely verification. Small reads see no measurable change.
Caution
Leave verification on for any store a network, another writer, or an untrusted user can reach.