Tuning
Start with the defaults. Change these options only when your workload requires it. The most important options are first.
The two options with the largest speed effect are FUSE options. With mountx/auto, put them in fuse: {…}. mountx ignores them when the host selects NFS. With a direct mountx/fuse import, put them at the top level.
#Caching — this is where the speed is
await mount(driver, "/mnt/point", {
fuse: {
attrTimeout: 10, // seconds the kernel may cache file attributes (default: 10)
entryTimeout: 10, // seconds it may cache name → file lookups (default: 10)
keepCache: true, // keep page cache between opens (default: true)
negativeTimeout: 0, // also cache "this file does not exist" (default: 0, off)
},
});These settings have a larger effect than JavaScript code optimization. On the measured host, attrTimeout and entryTimeout improved performance by 10 to 15 times. keepCache improved repeated reads by approximately four times. No measured code optimization has matched the first result.
Longer cache times can return stale data. Select settings based on other writers to the storage:
- Only the mount writes it: raise the timeouts. The kernel answers
statfrom cache and your driver never hears about it. - Something else writes it: lower them, or leave them and call
notifyInvalInode()when you know. negativeTimeouthas the same caveat, sharper: a file created behind mountx's back stays invisible for the whole timeout. It is a real saving for a build that stats hundreds of missing headers, and off by default for exactly that reason (libfuse's default too).
Note
Cached operations do not reach the driver. Therefore, the measured request rate is lower with the default timeouts than with timeouts disabled. The kernel does less work.
#Concurrency
await mount(driver, "/mnt/point", { fuse: { readers: 2 } });readers sets the number of pending reads on /dev/fuse.
This option controls use of the thread pool. /dev/fuse is a character device, so libuv classifies it as UV_FILE. Each pending read uses one thread until a request arrives. The default UV_THREADPOOL_SIZE is four. The process shares these threads with all other fs, dns, and zlib work, including driver I/O.
With the default pool, two readers leave two threads for a node:fs driver. Four readers can cause a deadlock.
To use more readers, also increase UV_THREADPOOL_SIZE. Set it before the process starts because libuv reads it one time:
UV_THREADPOOL_SIZE=32 node serve.ts # then readers: 8 is reasonableReplies do not use the pool because mountx writes them synchronously. Therefore, the complete budget is the readers value plus the threads that the driver needs.
#Write size
Raising the kernel's max_write from the 128 KiB default to 1 MiB is the one negotiated win with no reliable number behind it.
With an in-memory driver, this setting uses eight times fewer requests for the same number of bytes. In paired runs on the measured host, the speed ratio ranged from 0.95 to 1.4. Each result used only three iterations, so the variation is the same size as the measurement noise.
Use the setting, but do not plan around a specific speed ratio. It saves work for each request. The benefit should be visible when a driver performs real input/output (I/O) for each request. It can remain invisible when memory copying limits performance. mountx already asks for 1 MiB by default; maxRead caps the other direction and defaults to the kernel's own.
#Other mount options
await mount(driver, "/mnt/point", {
// shared — the same meaning on all three transports
readOnly: true, // the driver never sees writes
unmountTimeout: 10_000, // ms before unmount stops asking nicely
signals: true, // unmount on SIGINT/SIGTERM (default: true)
useDriverIno: true, // use the driver's own (dev, ino) identity (default: true)
fuse: {
fsname: "mydata", // what /proc/mounts shows as the device
subtype: "myfs", // makes the type read `fuse.myfs`
allowOther: false, // let other users in (default: false)
defaultPermissions: true, // let the kernel enforce mode bits (default: true)
},
"9p": {
mountMsize: 131_096, // bytes; the kernel's own default (128 KiB + P9_IOHDRSZ)
cache: "none", // 9P's biggest lever, and a correctness decision — see below
},
nfs: {
exportPath: "/", // where the client lands, not a boundary (default: "/")
nobrowse: true, // macOS only, on by default: keeps Finder and Spotlight out
hard: false, // retry forever instead of failing with EIO (default: false)
},
});The following four options need more explanation:
cache(9P) affects correctness and speed. It has the largest effect on 9P performance. The default iscache=nonebecause 9P has no invalidation channel ornotifyInvalInode(). Any higher mode assumes that only the mount changes the driver. If that assumption is false, the client silently reads stale data. It is also invisible in/proc/self/mounts, so a slow 9P mount is diagnosed from the code that mounted it. Whenlooseis safe, and whatnonecosts has both halves; the condition is narrower than it sounds.defaultPermissions(FUSE, on by default) hands mode-bit enforcement to the kernel, so your driver never has to make access decisions. Turning it off means your driver is answering for every process on the machine.nobrowse(macOS, on by default) hides the volume from the GUI. Finder and Spotlight scan a visible volume. For a JavaScript driver, this causes a burst of traffic and unexpected.DS_Storewrites. Set itfalseif the point of the mount is for someone to open it in Finder.exportPath(NFS) selects the directory the client lands on and confines nothing: everything reachable from the driver's root stays reachable. To serve a subtree, scope the driver instead. See the NFS page.
#9P's dispatch window
mountx/9p has one concurrency knob of its own: maxInFlight (default DEFAULT_MAX_IN_FLIGHT = 16), the requests a connection answers at once before the rest wait their turn.
P9ServerOptions defines this option, and MountP9Options extends that interface. Therefore, you can set it through createP9Server(), mount9p(), or mountx/auto. For mountx/auto, use mount(driver, mountpoint, { "9p": { maxInFlight: 32 } }).
This option bounds memory when a client pipelines thousands of requests in one delivery. It does not bound throughput. There is no 9P benchmark column yet, so no measured target is available.
See the 9P transport page for the full accounting.
#NFS's dispatch window
mountx/nfs has the same maxInFlight option on NfsServerOptions. MountNfsOptions extends that interface. You can set the option through createNfsServer(), mountNfs(), or mountx/auto. For mountx/auto, use mount(driver, mountpoint, { nfs: { maxInFlight: 128 } }).
The NFS default is 64, not the 9P default of 16. Its constant is DEFAULT_NFS_MAX_IN_FLIGHT, separate from 9P's DEFAULT_MAX_IN_FLIGHT.
The protocol requires this difference. NFSv4.1 can offer up to 64 ca_maxrequests slots. A conforming client can keep every granted slot busy. A smaller window would restrict traffic that the server agreed to accept.
NFSv3 has no slot table on the wire, but the Linux client's dynamic sunrpc.tcp_slot_table_entries lands in the same range.
Like the 9P option, it bounds memory rather than throughput. maxRecord limits one record, while maxInFlight limits the number of concurrent replies. Memory for active replies is at most maxInFlight multiplied by the largest reply. With the default 1 MiB rtmax or wtmax, this is approximately 64 MiB.
Without the limit, ten thousand pipelined 1 MiB READ requests could use ten gigabytes. Those calls require only about one megabyte on the wire. This can be normal client behavior rather than an attack.
A connection at the window stops reading its socket rather than parsing the rest of a burst.
Neither transport's window affects reply ordering: both answer in completion order, so a slow call never delays a fast one behind it.
See the NFS transport page for the full accounting.
#Telling the kernel something changed
If your storage changes behind mountx's back, drop the kernel's cached copy:
if (mounted.transport === "fuse") {
mounted.notifyInvalInode(42n); // forget this file's cached data and attributes
mounted.notifyInvalEntry(1n, "hello.txt"); // forget this name → file mapping
}Both functions take inode numbers as bigint and apply only to FUSE. The transport check narrows to their type. A direct mountx/fuse import exposes them without narrowing.
This is the release valve that makes long cache timeouts safe: keep attrTimeout high, and invalidate precisely when you know something moved.
#How fast is it, actually
All numbers on this page come from .agents/benchmarks.md. They were measured on one host in one session: Linux 6.12.96+deb13-amd64, 16 × Intel i7-10700K at 3.80 GHz, Node v24.18.0, and the in-memory driver.
The results are not portable. That file contains the complete tables and caveats. It also contains a paired run on the previous tree, which helps identify meaningful differences.
- Throughput: sequential read 6,221 MiB/s served from page cache, 1,568 MiB/s with the cache off; that second number is the transport's own. Sequential write reached 637 MiB/s over FUSE and 412 MiB/s over NFS. NFS sequential read reached 977 MiB/s. This is within a factor of 1.6 of FUSE uncached read. In the previous release, it was one quarter of that result.
- Request rate: a sequential FUSE client gets 11,900 requests per second with caching disabled. A client with 64 operations in flight sustains at least 18,300. Direct sampling on an older tree measured a peak near 50,000. With the shipped defaults the figure is lower, because most of that traffic never reaches the driver at all.
- Syscalls: a single-threaded client sees 2,000–3,500/sec on uncached metadata, since one syscall is two to five FUSE requests. That is the number you actually feel.
There is no measured number for the 9P or NFSv4.1 transports; neither has a benchmark column yet, so this page does not quote one.
#Next
- Troubleshooting: what to do when it wedges.
- FUSE and NFS: per-transport detail.
- Reference: every option, in full.