Web Platform · Browser Storage
The Origin Private File System and Synchronous Access Handles
How OPFS exposes a private per-origin directory tree, what an exclusive sync access handle offers a worker, and where byte-addressable storage beats record stores.
The origin private file system gives a page a real directory tree with real files, addressable by name and by byte offset, without any permission dialog and without exposing anything to the user’s file manager. It shares the File System Access API’s handle types (the same FileSystemDirectoryHandle and FileSystemFileHandle used for user-selected files), but the root is created by the browser and is invisible outside it. The result is a storage layer that behaves like a filesystem rather than a map, which makes it the right home for databases, archives, and anything a native library would have written with open and pwrite.
A private tree reached through handles
navigator.storage.getDirectory() resolves with a FileSystemDirectoryHandle that is the root of the origin’s private tree. From it, getFileHandle(name, { create: true }) and getDirectoryHandle(name, { create: true }) navigate and create, removeEntry(name) deletes, and entries(), keys(), and values() return async iterators over the children. A handle is a capability: holding one is what grants access, and there is no path string that can be passed around instead. Paths are therefore walked one component at a time, and names are single components — a name containing a slash is not a path, it is an invalid name.
Nothing in this tree appears in the file picker, and the user cannot browse it. The trade is that the origin cannot hand a user a file by path either; exporting means reading the bytes and offering a Blob download. The files count against the same per-origin quota as IndexedDB and the Cache API, and they are evicted with everything else.
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle('models', { create: true });
const handle = await dir.getFileHandle('weights.bin', { create: true });
const writable = await handle.createWritable();
await writable.write(new Uint8Array(1024));
await writable.close(); // publishes the new contents
Reads are snapshots, writes replace
handle.getFile() returns a File object describing the state at the moment of the call. It is not a live view: later writes do not change the bytes the File yields, and the File can be read as a stream or sliced like any other blob. Reading a file is therefore a copy into memory or a stream out of the file, with the usual cost proportional to the byte count.
handle.createWritable() returns a FileSystemWritableFileStream. Writes typically go to a temporary file, and the file’s visible contents change only when the stream is closed: the close algorithm writes the buffered bytes and swaps the new contents in, and user agents try to ensure that the file holds either its old contents or the new ones, never a partial result. Aborting the stream, or a crash in the middle, leaves the previous contents intact. This is the mechanism behind the API’s replace-on-close semantics, and it is why a writer does not have to build a temporary file and rename it. Passing { keepExistingData: true } seeds the temporary copy with the current bytes, which is what an edit-in-place wants; without it, the new content starts empty.
A writable stream supports write at the current position, seek to an offset, and truncate to a length. The position is per stream, and concurrent streams on the same file are not coordinated by the platform: two pages writing the same file through separate writables each build their own replacement, and the last close wins. Coordination across tabs is an application concern, and the Web Locks article covers the primitive for it.
Sync handles are exclusive, blocking, and worker-only
handle.createSyncAccessHandle() returns a FileSystemSyncAccessHandle whose methods are synchronous and operate on an explicit offset: read(buffer, { at }), write(buffer, { at }), truncate(size), getSize(), flush(), and close(). Calls block the calling thread instead of returning promises, which is why the interface is exposed only in dedicated workers. It is not available on window, nor in shared or service workers; a page that needs it must move the work into a dedicated worker.
The handle is exclusive. While one is open for a file, a second attempt to create one, from another worker, another tab, or the same worker, rejects with NoModificationAllowedError rather than waiting. That exclusivity is what makes synchronous random access safe: while the sync handle is held, no other context can open a writable stream or a second sync handle on the file, so writes cannot interleave. It also means the handle must be closed when the work finishes, because a leaked handle blocks the file for the lifetime of the worker.
// Inside a dedicated worker.
const root = await navigator.storage.getDirectory();
const file = await (await root.getDirectoryHandle('db')).getFileHandle('page1', { create: true });
const access = await file.createSyncAccessHandle();
const header = new Uint8Array(16);
const read = access.read(header, { at: 0 });
access.write(new Uint8Array([0x01]), { at: 16 });
access.flush(); // push the write toward durable storage
access.close();
flush is the durability boundary, in the same sense as the strict durability option on an IndexedDB readwrite transaction: without it, a completed write has updated the file’s contents as seen through the API but has not necessarily reached the disk. Code that reports success to the user before flushing is trusting a cache.
Directory order and naming
entries() and its variants yield children with no ordering guarantee. Unlike an IndexedDB store, where records are sorted by key, a directory is not defined to return names in any particular order, neither lexicographic nor creation order, so code that needs a stable sequence has to collect the names and sort them itself. The same applies to keys(), which is the cheap way to get names without opening each file.
Names are strings with a few restrictions: an empty name is invalid, a name containing a path separator cannot be created, and the API rejects names that would be interpreted as directory traversal. Case sensitivity is not specified and can follow the underlying file system, which differs between platforms, so two names that differ only in case may collide on one system and coexist on another. An application that derives file names from user input has to normalise them, because the error for a bad name arrives asynchronously from getFileHandle rather than at the point where the string was built.
What byte-addressable storage buys
An IndexedDB store organises records by key and gives indexes, ranges, and transactions; it does not expose a byte offset, cannot memory-map a region, and cannot hand a contiguous buffer to a library that expects one. The private file system inverts that: it offers offsets, sizes, truncation, and a buffer-oriented interface, and gives up keys, indexes, ranges, and transactions entirely.
The workloads that need this are the ones ported from native code. A SQLite build compiled to WebAssembly wants xRead and xWrite against a file descriptor with offsets, and the sync access handle is what makes that shape possible in a worker; the database engine supplies the B-tree, the journal, and the locking discipline itself. The same applies to archive readers, image codecs operating on large buffers, and any format defined by byte layout rather than by record.
The workloads that do not need it should stay in IndexedDB. Enumerating a directory is a linear walk over names with no ordering by value, no secondary indexes, and no way to query by anything except the name; building those features means storing metadata in the file system or in a database alongside it. Every record lookup in a hand-built format is code that a record store already implements and tests.
Mixing the two is normal: metadata, indexes, and queries in IndexedDB, bulk payloads as files named by the record key. That split keeps large blobs out of the structured clone path described in the structured clone article, where each read copies the bytes into the page heap, and it keeps the database small enough that a full scan of its records is cheap.
Lifecycle and limits
The private tree is filed under the origin’s storage key, so it is partitioned for third-party contexts and cleared when the user clears site data. There is no API to reserve space, only navigator.storage.estimate() and the persistence request; a write can fail with QuotaExceededError after the estimate reported headroom. Files left behind by a previous version of an application are not garbage-collected automatically — there is no expiry and no reference counting — so an application that creates files per session accumulates them until it removes them. A cleanup pass on startup, keyed by a directory listing and a timestamp stored alongside each file, is the only thing that prevents the tree from growing without bound.
References
- File System Standard — WHATWG
- MDN — FileSystemSyncAccessHandle — MDN Web Docs
- MDN — Origin private file system — MDN Web Docs