Origin Private File System (OPFS)
The Origin Private File System (OPFS) is a private, high-performance file store that each origin gets for free, reached through navigator.storage.getDirectory(). Unlike the user-visible File System Access API, OPFS files never appear in a picker and never touch the user’s real disk hierarchy — they live in an opaque, origin-scoped sandbox that the browser optimizes for raw throughput. That makes OPFS the storage of choice for SQLite-in-wasm databases, multi-megabyte media buffers, and any workload that needs streaming writes or random access rather than the whole-value reads of a key-value store. For foundational context review Browser Storage Fundamentals & Quotas, which frames where OPFS sits among localStorage, IndexedDB, and the Cache API. This guide covers the full API surface, a step-by-step implementation walkthrough, the concurrency rules that trip teams up, error handling, browser support, and the performance characteristics that justify reaching for it.
What OPFS is and when to reach for it
Every modern origin can request a private directory tree that is not part of the user’s filesystem and is invisible outside the browser. The entry point is asynchronous:
const root: FileSystemDirectoryHandle = await navigator.storage.getDirectory();
root is a FileSystemDirectoryHandle rooted at the origin’s private sandbox. From it you create and open nested directories and files, write them through a streaming API on the main thread, or — inside a Web Worker — open a synchronous access handle that is the fastest write path the platform offers. OPFS counts against the same origin quota as IndexedDB and the Cache API, so it is governed by the eviction rules in Storage Quotas & Eviction Policies; it is durable but not guaranteed permanent unless you call navigator.storage.persist().
Reach for OPFS when you have one of these workloads:
- A wasm-compiled database (SQLite is the canonical case) that needs byte-level random reads and writes against a single file.
- Large binary assets — video segments, audio, model weights, generated archives — where the whole-value semantics of Storing Blobs & Files in IndexedDB force a full re-serialization on every change.
- Streaming downloads or uploads where you want to write chunks as they arrive without buffering the entire payload in memory.
For small structured records, indexed queries, or transactional consistency across many keys, IndexedDB remains the better fit. The dedicated comparison OPFS vs IndexedDB for Large Binary Files walks the trade-off in detail.
API spec — handles, methods, and parameters
OPFS is built from two handle types and two write mechanisms. The table below lists the methods you will actually call.
| Member | On | Signature | Notes |
|---|---|---|---|
getDirectory() |
navigator.storage |
() => Promise<FileSystemDirectoryHandle> |
Returns the origin’s private root directory. |
getDirectoryHandle() |
directory handle | (name, { create?: boolean }) => Promise<FileSystemDirectoryHandle> |
create:true makes the subdirectory if absent. |
getFileHandle() |
directory handle | (name, { create?: boolean }) => Promise<FileSystemFileHandle> |
create:true makes the file if absent. |
removeEntry() |
directory handle | (name, { recursive?: boolean }) => Promise<void> |
Deletes a file or (with recursive) a non-empty directory. |
entries() |
directory handle | () => AsyncIterable<[string, Handle]> |
Async iterator over child names and handles. |
getFile() |
file handle | () => Promise<File> |
Snapshot File/Blob for reading on any thread. |
createWritable() |
file handle | ({ keepExistingData?: boolean }) => Promise<FileSystemWritableFileStream> |
Async writable stream; main-thread safe. |
write() |
writable stream | (data | { type, position, data }) => Promise<void> |
Append or seek-and-write; buffered until close(). |
truncate() |
writable stream | (size: number) => Promise<void> |
Resize the file. |
close() |
writable stream | () => Promise<void> |
Flushes buffered writes to the file. |
createSyncAccessHandle() |
file handle | () => Promise<FileSystemSyncAccessHandle> |
Worker only. Exclusive-locked synchronous handle. |
read() / write() |
sync handle | (buffer: ArrayBufferView, { at?: number }) => number |
Synchronous byte read/write at an offset; returns bytes moved. |
getSize() |
sync handle | () => number |
Current file size in bytes (synchronous). |
truncate() |
sync handle | (size: number) => void |
Synchronous resize. |
flush() |
sync handle | () => void |
Forces buffered bytes to storage. |
close() |
sync handle | () => void |
Releases the exclusive lock. |
The split is the single most important thing to internalize: createWritable() is async and works on the main thread, while createSyncAccessHandle() is synchronous, far faster, and only callable inside a Web Worker. A wasm SQLite build relies on the synchronous handle because its C code expects blocking read/write syscalls.
Implementation walkthrough
Write a file, then read it back
The async path works anywhere, including the main thread. Create the file with create:true, open a writable stream, write, and close.
async function writeText(path: string, contents: string): Promise<void> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(path, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(new TextEncoder().encode(contents));
await writable.close(); // close() is what actually persists the bytes
}
async function readText(path: string): Promise<string> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(path); // throws if absent
const file = await fileHandle.getFile();
return await file.text();
}
await writeText('notes/today.txt', 'OPFS round-trip works');
console.log(await readText('notes/today.txt')); // "OPFS round-trip works"
Note that getFileHandle('notes/today.txt', { create: true }) does not create intermediate directories — the name is a single path segment, and a / in it is not a directory separator. To nest, walk the tree explicitly:
async function getNestedFileHandle(
segments: string[],
): Promise<FileSystemFileHandle> {
let dir = await navigator.storage.getDirectory();
const fileName = segments[segments.length - 1];
for (const segment of segments.slice(0, -1)) {
dir = await dir.getDirectoryHandle(segment, { create: true });
}
return dir.getFileHandle(fileName, { create: true });
}
const handle = await getNestedFileHandle(['projects', 'demo', 'data.bin']);
Stream a large file without buffering it in memory
Because createWritable() returns a WritableStream-compatible target, you can pipe a fetch response straight into OPFS. Bytes land on disk as they arrive instead of accumulating in a Blob.
async function downloadToOpfs(url: string, path: string): Promise<void> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(path, { create: true });
const writable = await fileHandle.createWritable();
const response = await fetch(url);
if (!response.body) throw new Error('No response body to stream');
// pipeTo closes the destination stream on success, flushing to OPFS.
await response.body.pipeTo(writable);
}
await downloadToOpfs('/assets/model.bin', 'cache/model.bin');
For seek-and-write — overwriting a region in the middle of an existing file — pass the positional form to write():
const writable = await fileHandle.createWritable({ keepExistingData: true });
await writable.write({ type: 'write', position: 1024, data: patchBytes });
await writable.close();
The synchronous access handle (worker only)
Inside a worker, createSyncAccessHandle() gives blocking, offset-addressed reads and writes with no per-call promise overhead. This is the path SQLite-in-wasm uses.
// worker.ts — runs in a Web Worker, never the main thread.
self.onmessage = async (e: MessageEvent<{ path: string }>) => {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(e.data.path, { create: true });
// Takes an EXCLUSIVE lock on the file until close().
const handle = await fileHandle.createSyncAccessHandle();
try {
const encoder = new TextEncoder();
const payload = encoder.encode('synchronous write');
handle.write(payload, { at: 0 }); // returns bytes written
handle.flush(); // force to storage
const size = handle.getSize();
const buffer = new Uint8Array(size);
handle.read(buffer, { at: 0 }); // returns bytes read
self.postMessage(new TextDecoder().decode(buffer));
} finally {
handle.close(); // ALWAYS release the lock, even on error
}
};
Delete entries and recurse the directory tree
removeEntry() deletes a single child; pass { recursive: true } to delete a directory and everything beneath it. To enumerate, iterate entries() and recurse into directory handles.
async function listTree(
dir: FileSystemDirectoryHandle,
prefix = '',
): Promise<string[]> {
const out: string[] = [];
for await (const [name, handle] of (dir as any).entries()) {
const path = prefix ? `${prefix}/${name}` : name;
if (handle.kind === 'directory') {
out.push(`${path}/`);
out.push(...await listTree(handle as FileSystemDirectoryHandle, path));
} else {
out.push(path);
}
}
return out;
}
const root = await navigator.storage.getDirectory();
console.log(await listTree(root));
// Delete a whole subtree:
await root.removeEntry('cache', { recursive: true });
Concurrency and lifecycle
OPFS concurrency rules are strict and they are where most bugs originate. A FileSystemSyncAccessHandle takes an exclusive lock on its file: while it is open, no other sync handle and no writable stream can open the same file, and attempting to do so rejects with a NoModificationAllowedError. The lock lives until you call close() — which is why every worker that opens a sync handle must release it in a finally block, including on the error path.
The main thread never gets a sync handle, only the async writable stream, which itself takes a lock for the duration between createWritable() and close(). Two writable streams on the same file cannot be open simultaneously.
When several tabs or workers contend for the same OPFS file, the file-level lock is necessary but not sufficient: you also need to serialize who gets to open the handle. Coordinate that with the Web Locks API for Cross-Tab Coordination, acquiring a named lock before opening the sync handle so competing contexts queue instead of failing.
// Serialize access to a single OPFS file across tabs and workers.
async function withFileLock<T>(
name: string,
fn: () => Promise<T>,
): Promise<T> {
return navigator.locks.request(`opfs:${name}`, { mode: 'exclusive' }, fn);
}
await withFileLock('app.db', async () => {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('app.db', { create: true });
const handle = await fileHandle.createSyncAccessHandle();
try {
// ... exclusive critical section ...
handle.flush();
} finally {
handle.close();
}
});
Error handling
OPFS surfaces a small set of DOMException types. Handle them by name rather than message text.
| Error name | Cause | Recovery |
|---|---|---|
NotFoundError |
Opening a handle without create:true for a missing entry, or reading a deleted file. |
Retry with { create: true }, or treat as “absent” and seed defaults. |
NoModificationAllowedError |
A sync access handle or writable stream is already locking the file. | Back off and retry, ideally behind a Web Locks request so callers queue. |
QuotaExceededError |
The write would exceed the origin’s storage quota. | Free space (delete old entries), request persistence, or surface a clear “storage full” path. |
InvalidStateError |
Using a handle after close(), or a sync handle on the main thread. |
Reopen the handle; move sync work into a worker. |
TypeError |
createSyncAccessHandle invoked outside a Worker context. |
Run the code in a dedicated worker. |
async function safeOpenSync(
fileHandle: FileSystemFileHandle,
): Promise<FileSystemSyncAccessHandle | null> {
try {
return await fileHandle.createSyncAccessHandle();
} catch (err) {
if (err instanceof DOMException) {
if (err.name === 'NoModificationAllowedError') {
// Another context holds the lock — let the caller retry/queue.
return null;
}
if (err.name === 'QuotaExceededError') {
await freeUpSpace();
return null;
}
}
throw err;
}
}
Browser compatibility
OPFS itself reached Baseline in 2023 once Safari shipped it. The synchronous access handle is the part with the most history: its method shapes changed (older builds exposed read/write without the options object), and worker-only enforcement is universal.
| Feature | Chrome / Edge | Firefox | Safari (desktop) | iOS Safari |
|---|---|---|---|---|
navigator.storage.getDirectory() |
86+ | 111+ | 15.2+ | 15.2+ |
createWritable() async stream |
86+ | 111+ | 17+ | 17+ |
createSyncAccessHandle() (worker) |
102+ | 111+ | 16.4+ | 16.4+ |
Modern read/write with { at } options |
108+ | 111+ | 16.4+ | 16.4+ |
removeEntry({ recursive }) |
86+ | 111+ | 15.2+ | 15.2+ |
iOS Safari caveats worth flagging:
- On iOS Safari 16, the sync access handle landed in 16.4, but
createWritable()on the main thread did not arrive until 17. On 16.x you may need to do all writing through the worker-side sync handle. - iOS applies the same Intelligent Tracking Prevention eviction window to OPFS as to other storage for non-installed origins, so a 7-day-idle PWA can lose its OPFS data. Treat OPFS as durable-but-evictable and request
navigator.storage.persist()where supported. - Memory pressure on iOS can abort large sync writes; flush incrementally rather than holding one enormous buffer.
Performance and scale
For large binary and random-access workloads, OPFS is dramatically faster than IndexedDB. The reasons are structural: the synchronous access handle skips the structured-clone serialization that every IndexedDB value pays, skips transaction bookkeeping, and lets wasm code issue blocking byte-range reads and writes directly against a single file. Where IndexedDB must read or rewrite a whole value to change one field of a blob, OPFS overwrites just the affected bytes via write(buffer, { at }).
Practical guidance:
- Do heavy work in a worker with a sync handle. The async writable stream is convenient but the per-call promise overhead dominates for tight loops.
- Batch
write()calls andflush()deliberately; an over-eager flush after every small write erases the throughput advantage. - OPFS consumes the shared origin quota. A growing OPFS file competes with IndexedDB and the Cache API for the same budget and is subject to the same eviction, so monitor
navigator.storage.estimate(). - For sustained throughput, a single long-lived sync handle (held under a Web Lock) beats opening and closing a handle per operation, since each open/close pays lock-acquisition cost.
Frequently Asked Questions
Why can I only call createSyncAccessHandle() inside a Web Worker?
The synchronous access handle blocks the calling thread on every read, write, and flush. Allowing that on the main thread would freeze the UI, so the specification restricts it to worker contexts. Calling it on the main thread throws a TypeError. Use the async createWritable() stream on the main thread, or move the work into a dedicated worker.
Are OPFS files visible to the user or in the device filesystem?
No. OPFS is a private, origin-scoped sandbox with no connection to the user’s real file hierarchy and no file picker. Files cannot be opened by other apps and do not appear in Downloads. This is what distinguishes OPFS from the user-facing File System Access API, which writes to real, user-chosen locations.
Does OPFS have its own quota, or does it share with IndexedDB?
OPFS shares the single per-origin storage quota with IndexedDB and the Cache API. A large OPFS file reduces the budget available to the others, and all of them are subject to the same eviction policy. See Storage Quotas & Eviction Policies for the eviction mechanics and how to request persistence.
What happens if two tabs open a sync access handle on the same file?
The first acquires an exclusive lock; the second’s createSyncAccessHandle() rejects with NoModificationAllowedError until the first calls close(). To make contention orderly instead of error-prone, gate the open behind a named lock from the Web Locks API for Cross-Tab Coordination so callers queue rather than fail.
Related
- OPFS vs IndexedDB for Large Binary Files — a head-to-head decision guide for where multi-megabyte blobs belong.
- Web Locks API for Cross-Tab Coordination — serialize access to a single OPFS file across tabs and workers.
- Storing Blobs & Files in IndexedDB — the alternative store and its whole-value read/write semantics.
- Storage Quotas & Eviction Policies — the shared quota and eviction rules OPFS lives under.
- Browser Storage Fundamentals & Quotas — the parent guide to where OPFS fits among the storage primitives.