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.

OPFS access paths from main thread and worker A diagram showing how the main thread reaches OPFS through async writable streams while a Web Worker uses synchronous access handles that take an exclusive lock on the file. Main thread async only · createWritable() Web Worker createSyncAccessHandle() Writable stream write · close Sync handle read · write · flush OPFS file exclusive lock Coordinate competing handles with the Web Locks API

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:

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:

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:

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