Skip to content

Origin Private File System (OPFS)

The Origin Private File System (OPFS) is a sandboxed, per-origin file system that a web app reaches through navigator.storage.getDirectory(), with no permission prompts and nothing visible to the user. It stores directories and files of raw bytes inside the same quota as IndexedDB and Cache Storage. Its defining feature is FileSystemSyncAccessHandle: synchronous, in-place, byte-level reads and writes from a dedicated worker, which is exactly what SQLite and other WebAssembly databases need. For a PWA, OPFS is the right home for large offline media, editor project files, and a real SQL database that survives restarts.

Key takeaways

  • navigator.storage.getDirectory() returns the root of the origin's bucket file system, defined by the WHATWG File System Standard. It works in every engine: Chrome 86 (Android 109), Firefox 111, Safari 15.2.
  • There are three ways to do I/O: getFile() returns a read-only File snapshot, createWritable() returns an atomic, stream-based writer (Safari only since 26), and createSyncAccessHandle() returns synchronous in-place I/O, in dedicated workers only.
  • A sync access handle holds an exclusive lock on the file for every tab and worker of the origin. A second opener gets NoModificationAllowedError immediately, with no waiting. Multi-tab apps must coordinate, usually with the Web Locks API.
  • For SQLite, the official @sqlite.org/sqlite-wasm build offers opfs-sahpool (fastest, no special headers, one connection at a time), opfs (needs cross-origin isolation with COOP/COEP, handles concurrency) and opfs-wl (3.53+, locks with Web Locks, needs Atomics.waitAsync()).
  • OPFS shares the origin's quota, eviction and "clear site data" fate with IndexedDB. Call navigator.storage.persist() before storing gigabytes.
  • Chromium adds non-standard extras: remove(), locking modes for writers and access handles, and FileSystemObserver. Firefox and Safari implement move() fully, Chromium for files only. Feature-detect all of them.

What the OPFS is, and what it isn't

The File System Standard defines file system entries (files and directories), handles that point at them, and one entry point of its own: the bucket file system, a storage endpoint named "fileSystem" in each storage bucket. "Origin Private File System" is the web-developer name for that endpoint.

flowchart TD
    Key["Storage key: https://app.example"] --> Bucket["Storage bucket: default"]
    Bucket --> IDB[("indexedDB")]
    Bucket --> Caches[("caches")]
    Bucket --> FS["fileSystem: OPFS root, name is the empty string"]
    FS --> D1["media/"]
    FS --> D2[".opfs-sahpool/ owned by SQLite"]
    D1 --> F1["episode-12.mp4"]
    D1 --> F2["episode-13.mp4"]

What that means in practice:

  • It's private to the storage key. Other origins can't see it, and in third-party iframes it's partitioned by top-level site like all other storage (see Privacy & Storage Partitioning).
  • It isn't the user's disk. The spec says the contents aren't intended to be easily user-accessible and that "there is no expectation that files or directories with names matching the names of children of a bucket file system exist" on the host. Browsers store OPFS data inside the profile in their own format. Users can't open these files in their file manager, and your app can't read the user's files through OPFS.
  • There are no permission prompts. Every OPFS handle is always granted. Contrast this with the Chromium-only File System Access API pickers, which use the same handle interfaces but point at user-visible files and require consent.
  • It's managed storage. It counts against the origin's quota, is evicted together with IndexedDB and Cache Storage under storage pressure, and is deleted when the user clears site data. See Storage Quotas & Persistence.
  • Each Storage Bucket has its own OPFS root. In Chromium, bucket.getDirectory() on a Storage Bucket returns a separate root with that bucket's eviction and durability policy.

Where you can use it

Every OPFS interface is [SecureContext], so pages must be served over HTTPS (or from localhost).

Context Async API (getDirectory(), handles, getFile(), createWritable()) createSyncAccessHandle()
Window ✅ ❌ The method isn't exposed at all
Dedicated worker ✅ ✅
Shared worker ✅ ❌
Service worker ✅ ❌

FileSystemFileHandle.createSyncAccessHandle() is declared [Exposed=DedicatedWorker], so in a window the property doesn't even exist on the prototype. Feature detection for sync handles must therefore run inside a dedicated worker.

getDirectory() rejects with SecurityError when the context has no usable storage bottle, for example opaque origins, or storage blocked by the user or policy. Private browsing modes vary: when WebKit announced OPFS, it stated the API was unavailable in Safari Private Browsing windows. Always treat OPFS as optional and have a fallback path:

opfs-support.js
/** Detects OPFS capabilities. Run it in the context that will do the I/O. */
export async function detectOpfs() {
  const support = { root: false, writable: false, syncHandles: false, move: false, remove: false };
  if (typeof navigator.storage?.getDirectory !== "function") return support;
  try {
    await navigator.storage.getDirectory(); // can reject: SecurityError in restricted contexts
    support.root = true;
  } catch {
    return support;
  }
  if (typeof FileSystemFileHandle !== "undefined") {
    const proto = FileSystemFileHandle.prototype;
    support.writable = "createWritable" in proto; // Safari 26+, Chrome 86+, Firefox 111+
    support.syncHandles = "createSyncAccessHandle" in proto; // true only in dedicated workers
    support.move = "move" in proto; // non-standard; Chromium supports files only
  }
  support.remove = typeof FileSystemHandle !== "undefined" && "remove" in FileSystemHandle.prototype; // Chromium only
  return support;
}

getDirectory() resolves with a FileSystemDirectoryHandle for the root, whose name is the empty string. Everything else is reached from there. A handle is a pointer to a location: it doesn't lock anything or keep the entry alive, and it can outlive the entry it points to.

[Exposed=(Window,Worker), SecureContext, Serializable]
interface FileSystemDirectoryHandle : FileSystemHandle {
  async_iterable<USVString, FileSystemHandle>;
  Promise<FileSystemFileHandle> getFileHandle(USVString name, optional FileSystemGetFileOptions options = {});
  Promise<FileSystemDirectoryHandle> getDirectoryHandle(USVString name, optional FileSystemGetDirectoryOptions options = {});
  Promise<undefined> removeEntry(USVString name, optional FileSystemRemoveOptions options = {});
  Promise<sequence<USVString>?> resolve(FileSystemHandle possibleDescendant);
};
Method Behavior and errors
getFileHandle(name, { create = false }) Resolves with the file handle, creating an empty file if create is true. Rejects with NotFoundError if missing and not creating, and TypeMismatchError if a directory has that name
getDirectoryHandle(name, { create = false }) The same for directories. TypeMismatchError if a file has that name
removeEntry(name, { recursive = false }) Deletes a child. NotFoundError if missing, InvalidModificationError for a non-empty directory without recursive. Chromium also rejects with NoModificationAllowedError while the file is locked. Per the spec, recursive removal "can fail non-atomically", leaving part of the tree behind
resolve(handle) Resolves with the path components from this directory to a descendant, [] for the directory itself, or null if it isn't a descendant
entries(), keys(), values(), for await (const [name, handle] of dir) Async iteration over direct children. Entries created or deleted during iteration "might or might not be included". Order isn't specified. Safari supports the three methods since 15.2 and for await directly on the handle since 16.4
isSameEntry(other) Whether two handles point at the same entry

Names must be valid file names: not empty, not . or .., and without / or the platform's path separator (\ on Windows). Invalid names reject with TypeError. The spec also warns that the underlying file system may reject names the standard allows. Stick to a conservative character set, or encode user-supplied names (for example with encodeURIComponent, which leaves no /).

There's no path API. You walk the tree one component at a time, which is worth wrapping once:

opfs-paths.js
/** Splits "a/b/c.txt" into ["a", "b", "c.txt"], rejecting empty, "." and ".." parts. */
export function splitPath(path) {
  const parts = path.split("/").filter((part) => part !== "");
  if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
    throw new TypeError(`Invalid OPFS path: "${path}"`);
  }
  return parts;
}

/** Returns the directory handle for all but the last path component. */
export async function parentDirectory(path, { create = false } = {}) {
  let dir = await navigator.storage.getDirectory();
  for (const part of splitPath(path).slice(0, -1)) {
    dir = await dir.getDirectoryHandle(part, { create });
  }
  return dir;
}

export async function fileHandle(path, { create = false } = {}) {
  const dir = await parentDirectory(path, { create });
  return dir.getFileHandle(splitPath(path).at(-1), { create });
}

/** Deletes a file or directory tree. Resolves false if it didn't exist. */
export async function removePath(path) {
  const dir = await parentDirectory(path);
  try {
    await dir.removeEntry(splitPath(path).at(-1), { recursive: true });
    return true;
  } catch (error) {
    if (error.name === "NotFoundError") return false;
    throw error;
  }
}

/** Recursively yields { path, handle } for every file below `dir`. */
export async function* walk(dir, prefix = "") {
  for await (const [name, handle] of dir.entries()) {
    const path = `${prefix}/${name}`;
    if (handle.kind === "directory") yield* walk(handle, path);
    else yield { path, handle };
  }
}

Handles are serializable: you can postMessage() them to a worker or store them in IndexedDB, and the File System Access page explains why they still aren't paths. For OPFS, passing path strings between contexts is usually simpler, because every context of the origin can resolve the same path from its own getDirectory() root.

Renaming, moving and deleting

The standard has no rename. Engines ship a move() method that's still a proposal:

  • fileHandle.move(newName), move(destinationDirectory) and move(destinationDirectory, newName) work on OPFS files in Chrome 102+ (Android 109+), Firefox 111+ and Safari 15.2+.
  • Chromium exposes move() only on FileSystemFileHandle, so directories can't be moved there. Firefox and Safari support directory moves.
  • handle.remove({ recursive }) (Chrome 110+, not Firefox or Safari) deletes an entry through its own handle. The portable equivalent is parent.removeEntry(name, { recursive }).

Moving within the same file system is a metadata operation in practice, which makes it the cheapest way to "commit" a file written under a temporary name.

Reading files: getFile()

fileHandle.getFile() resolves with a standard File: name, size, lastModified, and a type the spec leaves "implementation-defined, based on for example the entry's name or its file extension". Don't rely on type. Store MIME types yourself.

The File is a snapshot. The spec warns that if the file changes or is removed after getFile(), "the returned File object will likely be no longer readable", and reading it then fails, typically with NotReadableError. Call getFile() again after every write.

Reads through the File are lazy, which gives you random access even from the main thread:

read-range.js
import { fileHandle } from "./opfs-paths.js";

/** Reads bytes [start, end) without loading the whole file. Works in windows and all workers. */
export async function readRange(path, start, end) {
  const file = await (await fileHandle(path)).getFile();
  return new Uint8Array(await file.slice(start, end).arrayBuffer());
}

/** Streams a file, for example into a Response or a decompression stream. */
export async function streamFile(path) {
  const file = await (await fileHandle(path)).getFile();
  return file.stream();
}

new Response(file) and new Response(file.slice(a, b)) also work, which is how a service worker serves OPFS content (see the complete example).

Writing asynchronously: createWritable()

fileHandle.createWritable({ keepExistingData = false }) returns a FileSystemWritableFileStream, a WritableStream with write(), seek() and truncate() helpers. It works in every context, and it's the same interface the File System Access API uses for user-visible files, which the File System Access page covers in depth. The OPFS-relevant semantics from the spec:

  • Writes go to a temporary copy. Changes aren't visible in the file until close(). The spec describes the typical implementation as writing to a temporary file and replacing the original on close, so readers see either the old contents or the complete new contents, never a partial write. abort() discards the copy.
  • keepExistingData: true copies the file first. The temporary file starts as a copy of the current contents, otherwise it starts empty. For a large file, opening a writer to append a few bytes costs a full copy. That's fine for occasional saves and a disaster for frequent small appends. Use a sync access handle for those.
  • It takes a shared lock. Several writers can coexist (each with its own temporary file, last close() wins), but a sync access handle can't be created while any writer is open, and vice versa. Lock conflicts reject with NoModificationAllowedError.
  • Quota applies. write() and truncate() reject with QuotaExceededError when the bucket's quota would be exceeded.
  • Writing past the end fills the gap with zeros. The spec allows implementations to store such gaps as sparse regions.

write() accepts data directly or a command object:

Argument Effect
BufferSource, Blob or string (UTF-8 encoded) Writes at the current position and advances it
{ type: "write", position, data } Writes data at position (or at the current position when omitted)
{ type: "seek", position } Moves the position. position is required, or write() rejects with TypeError
{ type: "truncate", size } Resizes the file, zero-filling when growing, and moves the position back to size if it was beyond it
opfs-write.js
import { fileHandle } from "./opfs-paths.js";

/** Replaces a file's contents atomically. Works in windows and all workers. */
export async function saveFile(path, data) {
  const handle = await fileHandle(path, { create: true });
  const writable = await handle.createWritable(); // empty temporary file
  try {
    await writable.write(data);
    await writable.close(); // swaps the new contents in; readers never see a partial file
  } catch (error) {
    await writable.abort().catch(() => {}); // discard the temporary file
    throw error;
  }
}

/** Streams a download to disk without buffering it in memory. */
export async function downloadToFile(path, url, { signal } = {}) {
  const response = await fetch(url, { signal });
  if (!response.ok || !response.body) throw new Error(`HTTP ${response.status} for ${url}`);
  const handle = await fileHandle(path, { create: true });
  const writable = await handle.createWritable();
  // pipeTo() closes the writable on success and aborts it on error or cancellation,
  // so a failed download leaves the previous contents of the file intact.
  await response.body.pipeTo(writable, { signal });
}

/** Patches a fixed-size header in place, keeping the rest of the file. */
export async function patchHeader(path, headerBytes) {
  const handle = await fileHandle(path);
  const writable = await handle.createWritable({ keepExistingData: true }); // full copy first
  await writable.write({ type: "write", position: 0, data: headerBytes });
  await writable.close();
}

Safari before 26 has no createWritable()

Safari implemented OPFS in 15.2 with directory and file handles and sync access handles, and WebKit's announcement dates getFile() to the following update (macOS 12.4 / iOS 15.4). FileSystemWritableFileStream only arrived in Safari 26. On older Safari, including iOS versions before 26, the only way to write an OPFS file is a sync access handle in a dedicated worker. If you support those versions, route all writes through a worker, as the complete example below does.

Chromium 121+ also accepts a non-standard mode option, createWritable({ mode: "exclusive" }), which takes an exclusive lock so only one writer can exist, and "siloed", which gives each writer its own swap file. The proposal is still under discussion, and Firefox and Safari don't implement it.

Synchronous access handles: createSyncAccessHandle()

[Exposed=DedicatedWorker] Promise<FileSystemSyncAccessHandle> createSyncAccessHandle();

[Exposed=DedicatedWorker, SecureContext]
interface FileSystemSyncAccessHandle {
  unsigned long long read(AllowSharedBufferSource buffer, optional FileSystemReadWriteOptions options = {});
  unsigned long long write(AllowSharedBufferSource buffer, optional FileSystemReadWriteOptions options = {});
  undefined truncate([EnforceRange] unsigned long long newSize);
  unsigned long long getSize();
  undefined flush();
  undefined close();
};
dictionary FileSystemReadWriteOptions { [EnforceRange] unsigned long long at; };

Despite its name, createSyncAccessHandle() itself returns a promise. The rules:

  • Dedicated workers only, and only for files in the bucket file system. On a user-picked file, it rejects with InvalidStateError.
  • Exclusive lock. Creating the handle takes an exclusive lock on the file. While it's open, no other sync handle or writable stream can be created for that file by any tab or worker of the origin, and attempts reject immediately with NoModificationAllowedError. close() releases the lock.
  • In-place I/O. Writes modify the file directly. There's no temporary copy and no atomic swap. A crash mid-write can leave partial data, so design your file format for recovery (length-prefixed records, checksums, journals).
  • A file position cursor. read() and write() without { at } use and advance an internal cursor, starting at 0. With { at }, they read or write at that offset and move the cursor to just after the bytes transferred.
  • Return values matter. read() and write() return the number of bytes transferred, which can be less than the buffer length. The spec says checking it "allows callers to detect and handle errors and partial writes". read() at or beyond end-of-file returns 0.
  • Durability needs flush(). write() changes may sit in OS buffers. flush() asks for them to reach storage. The spec explicitly says close() "does not guarantee that all file modifications will be immediately reflected in the underlying storage device", so call flush() first when it matters.
  • Errors. Every method except close() throws InvalidStateError after close(). write() and truncate() throw QuotaExceededError when the quota would be exceeded. Writing beyond the end zero-fills the gap.
  • Shared memory works. The parameter type is AllowSharedBufferSource, so you can read into and write from views on a SharedArrayBuffer, such as a shared WebAssembly memory, without copying.

The methods used to be asynchronous

In the first implementations, getSize(), truncate(), flush() and close() returned promises. They became synchronous in Chrome 108 and Safari 16.4. Firefox shipped the synchronous versions from the start in 111. Code that still needs to run on those older versions can await the results: awaiting a non-promise value is harmless, so await handle.flush() works with both shapes.

Example: a crash-tolerant append-only log

An append-only log with length-prefixed records shows the idioms: absolute offsets instead of the cursor, checking the byte counts, and ignoring a torn record at the tail after a crash:

log-worker.js
// Dedicated worker: new Worker(new URL("./log-worker.js", import.meta.url), { type: "module" })
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const HEADER_BYTES = 4; // uint32 little-endian payload length

let handle; // FileSystemSyncAccessHandle, held for the worker's lifetime
let size = 0; // logical end of the log (valid records only)

async function open() {
  const root = await navigator.storage.getDirectory();
  const file = await root.getFileHandle("events.log", { create: true });
  handle = await file.createSyncAccessHandle(); // rejects with NoModificationAllowedError if another worker holds it
  size = recover();
}

/** Scans the log and truncates a partially written record left by a crash. */
function recover() {
  const fileSize = handle.getSize();
  const header = new DataView(new ArrayBuffer(HEADER_BYTES));
  let at = 0;
  while (at + HEADER_BYTES <= fileSize) {
    if (handle.read(header, { at }) !== HEADER_BYTES) break;
    const length = header.getUint32(0, true);
    if (at + HEADER_BYTES + length > fileSize) break; // torn record
    at += HEADER_BYTES + length;
  }
  if (at !== fileSize) {
    handle.truncate(at);
    handle.flush();
  }
  return at;
}

function append(record) {
  const payload = encoder.encode(JSON.stringify(record));
  const frame = new Uint8Array(HEADER_BYTES + payload.byteLength);
  new DataView(frame.buffer).setUint32(0, payload.byteLength, true);
  frame.set(payload, HEADER_BYTES);

  let written = 0;
  while (written < frame.byteLength) {
    const n = handle.write(frame.subarray(written), { at: size + written });
    if (n === 0) {
      handle.truncate(size); // roll back the partial frame
      throw new Error("Write made no progress (disk full or I/O error)");
    }
    written += n;
  }
  size += frame.byteLength;
}

function* readAll() {
  const header = new DataView(new ArrayBuffer(HEADER_BYTES));
  let at = 0;
  while (at < size) {
    handle.read(header, { at });
    const length = header.getUint32(0, true);
    const payload = new Uint8Array(length);
    handle.read(payload, { at: at + HEADER_BYTES });
    yield JSON.parse(decoder.decode(payload));
    at += HEADER_BYTES + length;
  }
}

const ready = open();

self.onmessage = async ({ data }) => {
  try {
    await ready;
    if (data.type === "append") {
      for (const record of data.records) append(record);
      handle.flush(); // one flush per batch, not per record
      self.postMessage({ id: data.id, ok: true, size });
    } else if (data.type === "read") {
      self.postMessage({ id: data.id, ok: true, records: [...readAll()] });
    } else if (data.type === "close") {
      handle.flush();
      handle.close(); // release the exclusive lock for other tabs
      self.postMessage({ id: data.id, ok: true });
    }
  } catch (error) {
    self.postMessage({ id: data.id, ok: false, error: { name: error.name, message: error.message } });
  }
};

Chromium's locking modes for access handles

Chromium 121+ accepts a non-standard mode in createSyncAccessHandle({ mode }), from the same multiple readers and writers proposal:

Mode Coexists with Allowed operations
"readwrite" (default) Nothing All
"read-only" Other "read-only" handles read(), getSize(), close(). Writes throw NoModificationAllowedError
"readwrite-unsafe" Other "readwrite-unsafe" handles All, with no coordination: concurrent writes race

Firefox and Safari ignore the option and always take the exclusive lock, so code that relies on shared read handles must fall back to one owner per file.

Locking and multiple tabs

The spec models locks per file entry, shared by every context of the origin:

Operation Lock Blocks Blocked by
getFile() None Nothing Nothing (but the snapshot goes stale when others write)
createWritable() Shared, until close() or abort() Sync access handles Sync access handles
createSyncAccessHandle() Exclusive, until close() Everything else that locks Any writable stream or sync handle

Locks are never queued. A conflicting request fails right away with NoModificationAllowedError. A PWA opened in two tabs, each starting a worker that opens the same file with a sync handle, will hit this on the second tab. Three workable strategies:

  1. Hold handles briefly and queue with Web Locks. Wrap every open-use-close sequence in navigator.locks.request() with a per-file lock name. Other contexts wait in a fair FIFO queue instead of failing. The Web Locks API is available in windows and all workers (Chrome 69, Firefox 96, Safari 15.4).
  2. Elect a single owner. One tab's worker holds the handles for the app's lifetime. It acquires a long-lived Web Lock, which the browser releases automatically when that worker's tab closes. Other tabs forward requests to the owner (for example through a BroadcastChannel) or wait for the lock. This is what SQLite's opfs-sahpool VFS needs.
  3. Use Chromium's shared modes where they fit, with an exclusive fallback elsewhere.
opfs-locks.js
/**
 * Runs fn while holding an origin-wide lock for `path`. Cooperating tabs and workers
 * queue instead of failing with NoModificationAllowedError.
 */
export function withFileLock(path, fn) {
  return navigator.locks.request(`opfs:${path}`, fn);
}

/**
 * Resolves once this context owns `name`, and keeps it until the context ends.
 * `onWait` is called if another tab owns it first.
 */
export function becomeOwner(name, { onWait } = {}) {
  return new Promise((resolve) => {
    const hold = () => {
      resolve();
      return new Promise(() => {}); // never settles: the lock lives as long as this context
    };
    navigator.locks.request(name, { ifAvailable: true }, (lock) => {
      if (lock) return hold();
      onWait?.();
      navigator.locks.request(name, hold); // queue behind the current owner
      return undefined;
    });
  });
}

A SharedWorker looks like the natural single owner, but it can't create sync access handles, which are dedicated-worker only. It can coordinate, but the I/O must still happen in a dedicated worker.

SQLite Wasm on OPFS

Why SQLite needs synchronous I/O

SQLite talks to storage through a VFS (virtual file system) whose xRead, xWrite, xSync and xLock calls are synchronous C functions. A WebAssembly build can't await inside them without heavy machinery such as Asyncify or JSPI. OPFS sync access handles provide exactly the synchronous primitives SQLite expects, which is why SQLite on OPFS runs in a worker at near-native speed, while SQLite on IndexedDB needs an asynchronous build and batching tricks.

The official build: @sqlite.org/sqlite-wasm

The SQLite project publishes its WebAssembly build as the @sqlite.org/sqlite-wasm npm package (3.53.4-build1 at the time of writing). It wraps the canonical sqlite3.wasm unchanged, adds TypeScript types, and exposes three API levels: a C-style API, the object-oriented oo1 API (db.exec(), db.selectObjects(), db.transaction()), and the Worker1 / Promiser1 message APIs. The package's README states that as of 2026-04-15 the Worker1 and Promiser1 APIs are deprecated. They won't be removed, but their author actively discourages them. Load the library inside your own worker and use oo1 there, as shown below. In Node.js, the package supports only in-memory databases.

Choosing a VFS

VFS Since Cross-origin isolation (COOP/COEP) Concurrency across tabs Notes
opfs 3.40 era Required (uses SharedArrayBuffer and Atomics) Yes: locks are acquired and released around each transaction, with retries, surfacing SQLITE_BUSY under contention Starts its own helper worker. Transparent file names (a database at /app.sqlite3 is that path in OPFS). Incompatible with Safari before 17 because of a sub-worker storage bug
opfs-wl 3.53.0 Required Yes, with Web Locks (a fair FIFO queue) Needs Atomics.waitAsync() (Chrome 90, Safari 16.4, Firefox 145). Otherwise identical to opfs. oo1 class: OpfsWlDb
opfs-sahpool 3.43 Not required No: one instance per pool directory at a time. pauseVfs() and unpauseVfs() (3.50+) allow cooperative hand-off The fastest option. Pre-opens a pool of sync access handles. File names are virtual (stored in the pool's own metadata). Paths must be absolute. Works in all major browsers released since March 2023
kvvfs 3.40 era Not required n/a Stores the database in localStorage or sessionStorage (main thread only, about 5 MB). Only for tiny data
WASMFS + OPFS 3.43, custom build Required for the whole library No Unsupported by the SQLite project, and not in the canonical build

The SQLite documentation's own advice: clients that value performance over concurrency, or can't set the COOP/COEP headers, should use opfs-sahpool. Clients that need multi-tab concurrency should use opfs or opfs-wl. The same documentation notes that testing in March 2026 handled 8 to 10 concurrent workers on one opfs database, provided each keeps its locking brief and handles SQLITE_BUSY (3.53.0 added a sqlite3_js_retry_busy() helper for this).

Other details from the SQLite docs worth knowing:

  • WAL mode is possible since 3.47, but only with PRAGMA locking_mode=exclusive, which removes all concurrency from the opfs VFS. The pool VFS may gain a little performance from it.
  • opfs-sahpool's pool capacity defaults to 6 files. It needs at least twice the number of databases (for journals), possibly more depending on temp_store. Grow it with reserveMinimumCapacity().
  • The pool VFS provides importDb() and exportFile() for backup and restore, and the opfs VFS supports a delete-before-open=1 URI flag (3.46+) for recovering from a corrupted file.
  • "Mysterious disappearance of databases" is a known report category. Antivirus software, cleaner tools, browser storage settings and eviction can all delete OPFS data, which is another reason to request persistence and keep a server copy.

Cross-origin isolation for the opfs and opfs-wl VFSes

SharedArrayBuffer is available only in cross-origin isolated contexts, which requires two response headers on the document (and on worker scripts):

_headers
/*
  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: require-corp
nginx.conf (excerpt)
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

Before you adopt them, check the consequences for a PWA:

  • Every cross-origin subresource must opt in. With require-corp, images, scripts, fonts and iframes from other origins load only if they send Cross-Origin-Resource-Policy or are fetched with CORS. Third-party embeds often break. Cross-Origin-Embedder-Policy: credentialless relaxes this for no-cors requests (Chrome 96, Firefox 119) but Safari doesn't support it, so you can't rely on it for iOS.
  • same-origin COOP severs popups. Cross-origin windows you open lose their window.opener link, which breaks OAuth and payment flows that post results back to the opener. See Authentication & Passkeys and Payments.
  • Your service worker must preserve the headers. Navigation responses served from Cache Storage must carry COOP and COEP themselves. HTML cached before you added the headers, or synthesized with new Response(), silently produces a non-isolated page. Check self.crossOriginIsolated at runtime, and see Handling Fetch Events.
  • Dev servers need the headers too. The package README shows the Vite setting (server.headers) and recommends excluding the package from dependency pre-bundling (optimizeDeps.exclude).

If those costs are too high, use opfs-sahpool and coordinate tabs with Web Locks, as below.

A database worker with opfs-sahpool

This worker owns the database, runs schema migrations with PRAGMA user_version, and serves queries to the page over postMessage. The single-owner lock handles the pool VFS's one-instance rule: a second tab waits (and says so) until the first tab closes.

db-worker.js
// Module worker: new Worker(new URL("./db-worker.js", import.meta.url), { type: "module" })
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";

const DB_PATH = "/app.sqlite3"; // opfs-sahpool paths must be absolute

// Migration i upgrades user_version i to i + 1. Never edit a shipped migration; append.
const MIGRATIONS = [
  `CREATE TABLE notes (
     id TEXT PRIMARY KEY,
     title TEXT NOT NULL,
     body TEXT NOT NULL DEFAULT '',
     updated_at INTEGER NOT NULL
   );
   CREATE INDEX notes_updated_at ON notes(updated_at);`,
  `ALTER TABLE notes ADD COLUMN folder_id TEXT;
   CREATE INDEX notes_folder_updated ON notes(folder_id, updated_at);`,
];

let db;
let pool;

const ready = (async () => {
  // opfs-sahpool can be installed by only one context per pool directory at a time.
  await becomeOwner("sqlite:opfs-sahpool", () => self.postMessage({ type: "status", status: "waiting-for-other-tab" }));
  const sqlite3 = await sqlite3InitModule();
  pool = await sqlite3.installOpfsSAHPoolVfs(); // default name "opfs-sahpool", directory ".opfs-sahpool"
  await pool.reserveMinimumCapacity(6); // db + journal + temp files, with headroom
  db = new pool.OpfsSAHPoolDb(DB_PATH);
  db.exec("PRAGMA foreign_keys = ON;");
  migrate();
  self.postMessage({ type: "status", status: "ready", sqlite: sqlite3.version.libVersion });
})();

function becomeOwner(name, onWait) {
  return new Promise((resolve) => {
    const hold = () => {
      resolve();
      return new Promise(() => {}); // released automatically when this worker terminates
    };
    navigator.locks.request(name, { ifAvailable: true }, (lock) => {
      if (lock) return hold();
      onWait();
      navigator.locks.request(name, hold);
      return undefined;
    });
  });
}

function migrate() {
  const current = db.selectValue("PRAGMA user_version");
  if (current > MIGRATIONS.length) {
    throw new Error(`Database schema v${current} is newer than this code (v${MIGRATIONS.length}). Reload.`);
  }
  for (let version = current; version < MIGRATIONS.length; version++) {
    db.transaction((tx) => {
      tx.exec(MIGRATIONS[version]);
      tx.exec(`PRAGMA user_version = ${version + 1}`); // committed atomically with the migration
    });
  }
}

const handlers = {
  query: ({ sql, bind }) => db.selectObjects(sql, bind),
  run: ({ sql, bind }) => {
    db.exec({ sql, bind });
    return { changes: db.changes() };
  },
  batch: ({ statements }) =>
    db.transaction(() => {
      for (const { sql, bind } of statements) db.exec({ sql, bind });
      return statements.length;
    }),
  export: () => pool.exportFile(DB_PATH), // Uint8Array snapshot for backups
};

self.onmessage = async ({ data: { id, type, payload } }) => {
  try {
    await ready;
    const handler = handlers[type];
    if (!handler) throw new TypeError(`Unknown request type "${type}"`);
    const result = handler(payload ?? {});
    const transfer = result instanceof Uint8Array ? [result.buffer] : [];
    self.postMessage({ id, ok: true, result }, transfer);
  } catch (error) {
    self.postMessage({ id, ok: false, error: { name: error.name, message: error.message } });
  }
};
db-client.js
const worker = new Worker(new URL("./db-worker.js", import.meta.url), { type: "module" });
const pending = new Map();
let nextId = 1;

worker.onmessage = ({ data }) => {
  if (data.type === "status") {
    dispatchEvent(new CustomEvent("db-status", { detail: data })); // e.g. show "open in another tab"
    return;
  }
  const request = pending.get(data.id);
  if (!request) return;
  pending.delete(data.id);
  if (data.ok) request.resolve(data.result);
  else request.reject(Object.assign(new Error(data.error.message), { name: data.error.name }));
};

worker.onerror = (event) => {
  // The worker failed to load or threw during initialization: fail everything in flight.
  for (const { reject } of pending.values()) reject(new Error(event.message || "Database worker failed"));
  pending.clear();
};

function call(type, payload) {
  const id = nextId++;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });
    worker.postMessage({ id, type, payload });
  });
}

export const query = (sql, bind) => call("query", { sql, bind });
export const run = (sql, bind) => call("run", { sql, bind });
export const batch = (statements) => call("batch", { statements });
export const exportDatabase = async () =>
  new Blob([await call("export")], { type: "application/vnd.sqlite3" });

// Usage:
// await run("INSERT INTO notes (id, title, updated_at) VALUES (?, ?, ?)", [crypto.randomUUID(), "Hi", Date.now()]);
// const rows = await query("SELECT * FROM notes WHERE folder_id IS ? ORDER BY updated_at DESC LIMIT 50", [null]);

To use the opfs VFS instead, serve the app cross-origin isolated and replace the pool setup with db = new sqlite3.oo1.OpfsDb("/app.sqlite3"), guarded by if (sqlite3.oo1.OpfsDb), which exists only when the VFS installed successfully. Drop the owner lock, since that VFS locks per transaction, and add SQLITE_BUSY retries instead.

Other SQLite and database options

  • wa-sqlite by Roy Hashimoto, whose AccessHandlePoolVFS inspired opfs-sahpool, ships synchronous, Asyncify and JSPI builds plus example VFSes for OPFS (OPFSCoopSyncVFS, OPFSAdaptiveVFS, OPFSWriteAheadVFS, OPFSPermutedVFS, OPFSAnyContextVFS, AccessHandlePoolVFS) and IndexedDB (IDBBatchAtomicVFS, IDBMirrorVFS). Its discussions on GitHub are the best public record of OPFS concurrency trade-offs.
  • RxDB offers an OPFS storage as a premium plugin. See IndexedDB alternatives.

Use cases for OPFS in PWAs

  • Offline media. Podcast episodes, videos and map tiles downloaded for offline use, written in chunks with resume support and served to <video> and <audio> through the service worker with HTTP range responses. The complete example below does exactly this.
  • Local-first apps on SQLite. Relational data, joins, full-text search (FTS5 is in the canonical build) and transactions, persisted in OPFS.
  • Editors. Image, audio, video and document editors keep large working files and autosave journals in OPFS, then export to a user-visible file with showSaveFilePicker() where available (Chromium) or a download link elsewhere.
  • Large upload staging. Chunk a large file into OPFS so an interrupted upload resumes after a reload without asking the user to pick the file again.
  • WebAssembly toolchains. Compilers, emulators and machine-learning runtimes that expect a POSIX-like file system can map it onto sync access handles.
  • Append-heavy logs and caches where createWritable()'s copy-on-open would be too slow.

OPFS vs IndexedDB vs Cache Storage

OPFS IndexedDB Cache Storage
Unit of storage Files of bytes in directories Structured-cloned records in object stores HTTP Response objects keyed by request
Partial reads ✅ File.slice(), read({ at }) ❌ Whole values (a stored Blob can be sliced) ✅ Blob.slice() after response.blob()
In-place partial writes ✅ Sync handles, or writable streams with a copy ❌ Rewrite the whole record ❌ Replace the whole entry
Streaming writes ✅ pipeTo(writable) or chunked write() ❌ Values must be complete in memory ✅ cache.put() streams the body
Atomic multi-item writes ❌ (one file per close()) ✅ Transactions across stores ⚠️ addAll() only
Queries and indexes ❌ (bring SQLite) ✅ Key ranges and indexes URL matching only
Synchronous API ✅ Dedicated workers ❌ ❌
Usable in the service worker ✅ Async API ✅ ✅
Multi-tab concurrency Manual (exclusive locks) ✅ Built-in transaction scheduling ✅
Best for Large binaries, random access, databases App data, queues, metadata HTTP responses for the fetch handler

A common, robust split: bytes in OPFS, facts about the bytes in IndexedDB. IndexedDB gives you atomic, queryable metadata (URL, ETag, size, completeness, last access). OPFS gives you streaming, range-readable storage for the content.

Quota, persistence and eviction

OPFS has no quota of its own. The spec registers the bucket file system as a storage endpoint with a null quota, so it draws from the bucket's shared quota:

  • navigator.storage.estimate() includes OPFS in usage. Chromium's non-standard usageDetails.fileSystem breaks it out. See Measuring usage.
  • Exceeding the quota makes FileSystemWritableFileStream.write() / truncate() reject and FileSystemSyncAccessHandle.write() / truncate() throw, with QuotaExceededError. A download that fails halfway leaves a partial file behind, so decide whether to keep it for resume or truncate it.
  • Eviction is all-or-nothing per bucket. Under storage pressure, a best-effort origin loses OPFS together with IndexedDB, Cache Storage and its service worker registration. Request persistence before large downloads: await navigator.storage.persist(). Chromium and Safari decide silently based on heuristics such as installation, and Firefox prompts.
  • Safari's 7-day cap. When WebKit introduced OPFS, it said its lifetime is the same as other persistent storage types like IndexedDB and localStorage. Treat OPFS in Safari (outside Home Screen web apps) as subject to the same script-writable storage policy described in Storage Quotas & Persistence.
  • Check before you write gigabytes. Estimates are imprecise, and current Chrome reports a predictable quota of usage + 10 GiB instead of the enforced limit (the built-in default since Chrome 148, after a staged rollout from Chrome 144), so a pre-flight check can't guarantee success. Still, check, and handle QuotaExceededError mid-write anyway.
preflight.js
/** Best-effort check before a large download. Never a guarantee: always handle QuotaExceededError too. */
export async function prepareForLargeWrite(expectedBytes) {
  const persisted = (await navigator.storage.persisted?.()) || (await navigator.storage.persist?.());
  const { usage = 0, quota = 0 } = await navigator.storage.estimate();
  return {
    persisted: Boolean(persisted),
    likelyFits: quota - usage > expectedBytes * 1.1, // leave headroom for metadata and temp files
  };
}

Complete example: an offline media store backed by a worker

This example builds a "download for offline" feature for a podcast or video PWA from the pieces above. A dedicated worker downloads media with sync access handles, the page talks to it through a promise-based wrapper, and the service worker serves the stored files to <audio> and <video> with range support, with or without a network.

sequenceDiagram
    participant Page
    participant W as Media worker
    participant FS as OPFS
    participant SW as Service worker
    Page->>W: download(url)
    W->>W: wait for the Web Lock of this item
    W->>FS: sync handles on key.bin and key.json
    W->>FS: write chunks, flush every 8 MiB, record the durable offset
    W->>FS: rewrite key.json with complete set to true
    W-->>Page: resolve with key, type and size
    Page->>SW: GET /offline-media/key with a Range header
    SW->>FS: getFile() on key.json and key.bin
    SW-->>Page: 206 Partial Content from file.slice()

The design decisions:

  • Two files per item. media/<key>.bin holds the bytes. media/<key>.json holds the URL, MIME type, size, ETag, a durable offset and a complete flag. The key is the SHA-256 of the URL in hex, which is always a valid file name of fixed length.
  • The sidecar is the commit marker. The worker sets complete: true only after the final flush(), and the service worker serves nothing else, so a half-written file never reaches a media element. A torn sidecar write fails JSON.parse() and reads as "not stored".
  • Resumable by design. Every 8 MiB the worker flushes the data and records the flushed length as durable. An interrupted download (aborted, offline, or killed with its tab) resumes with Range: bytes=<durable>- and If-Range: <ETag>, and bytes past durable are truncated because they may never have reached the disk. If the file changed on the server, If-Range makes the server send the whole new file instead of a mismatched tail.
  • One writer per item across tabs. Each item's work runs under the Web Lock media:<key>. A second tab that asks for the same episode waits, then finds it complete, instead of failing with NoModificationAllowedError.
  • Only the worker writes. The service worker only reads, through async handles and getFile(), which take no lock.
  • Explicit playback URLs. The page gives media elements /offline-media/<key> only for stored items, so the service worker never intercepts media it doesn't have and never has to push uncached range requests through fetch() (see Range requests and media).

Browser baseline for this example

The worker calls getSize(), truncate(), flush() and close() synchronously and uses Web Locks, so it needs Chrome 108 (Android 109), Firefox 111 or Safari 16.4. Every write goes through a sync access handle, so it doesn't need createWritable() and works in Safari versions before 26. navigator.storage.estimate() is feature-detected, because Safari has it only since 17.

The worker: media-worker.js

media-worker.js
// Dedicated module worker that owns every write under /media in OPFS.
// Created by media-store.js: new Worker(new URL("./media-worker.js", import.meta.url), { type: "module" })
const FLUSH_EVERY = 8 * 1024 * 1024; // bytes between flushes: bounds what a crash can cost
const PROGRESS_EVERY_MS = 250;
const encoder = new TextEncoder();
const jobs = new Map(); // request id -> { key, controller } for downloads in this worker

// Platform errors mapped to stable reasons the page can switch on.
const REASONS = {
  QuotaExceededError: "quota",
  NoModificationAllowedError: "locked", // a handle held by code outside our Web Locks
  AbortError: "aborted",
  TypeError: "network", // fetch() and stream reads reject with TypeError on network failure
  SecurityError: "unavailable", // no storage access in this context
};

const fail = (reason, message) => Object.assign(new Error(message), { reason });

async function mediaDir() {
  const root = await navigator.storage.getDirectory();
  return root.getDirectoryHandle("media", { create: true });
}

/** SHA-256 of the URL in hex: fixed length and always a valid file name. */
async function keyFor(url) {
  const digest = await crypto.subtle.digest("SHA-256", encoder.encode(url));
  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}

/** write() may transfer fewer bytes than requested, so loop until everything is written. */
function writeAll(access, bytes, at) {
  for (let done = 0; done < bytes.byteLength; ) {
    const n = access.write(bytes.subarray(done), { at: at + done });
    if (n === 0) throw new Error("Write made no progress");
    done += n;
  }
}

/** Rewrites the sidecar in place. A torn write fails JSON.parse() and reads as "not stored". */
function writeMeta(access, meta) {
  const bytes = encoder.encode(JSON.stringify(meta));
  access.truncate(0);
  writeAll(access, bytes, 0);
  access.flush();
}

async function readMeta(dir, key) {
  try {
    const file = await (await dir.getFileHandle(`${key}.json`)).getFile();
    return JSON.parse(await file.text());
  } catch {
    return null; // missing, torn or being rewritten
  }
}

/** Deletes the sidecar first, so the service worker stops serving before the bytes go. */
async function removeFiles(dir, key) {
  let removed = false;
  for (const name of [`${key}.json`, `${key}.bin`]) {
    try {
      await dir.removeEntry(name);
      removed = true;
    } catch (error) {
      if (error.name !== "NotFoundError") throw error; // e.g. NoModificationAllowedError: open elsewhere
    }
  }
  return removed;
}

/** Best-effort pre-flight. Estimates are coarse, so write errors are handled as well. */
async function checkQuota(bytesNeeded) {
  if (bytesNeeded === null || typeof navigator.storage.estimate !== "function") return; // unknown, or Safari < 17
  const { usage = 0, quota = Infinity } = await navigator.storage.estimate();
  if (quota - usage < bytesNeeded * 1.1) {
    throw fail("quota", `Needs ${bytesNeeded} bytes, about ${Math.max(quota - usage, 0)} available`);
  }
}

/** Total size from Content-Range (206) or Content-Length (200), or null when unknown. */
function expectedSize(response, offset) {
  if (response.status === 206) {
    const match = /^bytes (\d+)-\d+\/(\d+|\*)$/.exec(response.headers.get("Content-Range") ?? "");
    if (!match || Number(match[1]) !== offset) throw fail("network", "Unexpected Content-Range");
    return match[2] === "*" ? null : Number(match[2]);
  }
  const length = response.headers.get("Content-Length");
  return length === null ? null : Number(length);
}

async function download({ url, type }, id) {
  const controller = new AbortController();
  const job = { key: null, controller };
  jobs.set(id, job); // before the first await, so an abort message that follows isn't lost
  try {
    job.key = await keyFor(url);
    // One download per item across all tabs: another tab's worker queues here instead of
    // failing with NoModificationAllowedError in createSyncAccessHandle().
    return await navigator.locks.request(`media:${job.key}`, { signal: controller.signal }, () =>
      downloadLocked(url, type, job.key, id, controller.signal),
    );
  } finally {
    jobs.delete(id);
  }
}

async function downloadLocked(url, type, key, id, signal) {
  const dir = await mediaDir();
  const previous = await readMeta(dir, key);
  if (previous?.complete) return { key, ...previous }; // stored earlier, possibly by another tab

  const data = await (await dir.getFileHandle(`${key}.bin`, { create: true })).createSyncAccessHandle();
  let metaAccess = null;
  let discard = false;
  try {
    metaAccess = await (await dir.getFileHandle(`${key}.json`, { create: true })).createSyncAccessHandle();
    // Resume only from bytes that were flushed and recorded before the interruption.
    let offset = previous?.url === url ? Math.min(previous.durable ?? 0, data.getSize()) : 0;
    const headers = {};
    if (offset) {
      headers.Range = `bytes=${offset}-`;
      // Only strong validators are allowed in If-Range. A changed file then comes back as a full 200.
      if (previous.etag && !previous.etag.startsWith("W/")) headers["If-Range"] = previous.etag;
    }
    let response = await fetch(url, { signal, headers });
    // 416: the flushed prefix is already the whole file, or the file shrank. A 206 without a readable
    // Content-Range (cross-origin, not exposed) can't be verified. Either way, start over.
    if (offset && (response.status === 416 || (response.status === 206 && !response.headers.has("Content-Range")))) {
      await response.body?.cancel();
      offset = 0;
      response = await fetch(url, { signal });
    }
    if (!response.ok || !response.body) throw fail("network", `HTTP ${response.status} for ${url}`);
    if (response.status !== 206) offset = 0; // a full response: Range was ignored or If-Range failed
    const size = expectedSize(response, offset);
    await checkQuota(size === null ? null : size - offset);

    const meta = {
      url,
      type: type || response.headers.get("Content-Type") || "application/octet-stream",
      etag: response.headers.get("ETag"),
      size,
      durable: offset,
      complete: false,
    };
    data.truncate(offset); // drop bytes past the resume point: they may never have reached the disk
    writeMeta(metaAccess, meta);

    const reader = response.body.getReader();
    let lastProgress = 0;
    for (;;) {
      const { done, value } = await reader.read(); // rejects with AbortError when aborted
      if (done) break;
      writeAll(data, value, offset); // throws QuotaExceededError when the bucket is full
      offset += value.byteLength;
      if (offset - meta.durable >= FLUSH_EVERY) {
        data.flush();
        meta.durable = offset; // recorded only after the flush
        writeMeta(metaAccess, meta);
      }
      if (performance.now() - lastProgress >= PROGRESS_EVERY_MS) {
        lastProgress = performance.now();
        self.postMessage({ id, type: "progress", received: offset, total: size });
      }
    }
    if (size !== null && offset !== size) throw fail("network", `Body ended at ${offset} of ${size} bytes`);

    data.flush();
    Object.assign(meta, { size: offset, durable: offset, complete: true, savedAt: Date.now() });
    writeMeta(metaAccess, meta); // the commit: from now on the service worker serves the file
    return { key, ...meta };
  } catch (error) {
    // Out of space: a partial file only adds to the pressure. Other failures keep it for resume.
    discard = error.name === "QuotaExceededError" || error.reason === "quota";
    throw error;
  } finally {
    metaAccess?.close();
    data.close(); // release the exclusive locks
    if (discard) await removeFiles(dir, key).catch(() => {});
  }
}

async function remove({ url }) {
  const key = await keyFor(url);
  for (const job of jobs.values()) if (job.key === key) job.controller.abort(); // stop our own download
  // Waits for a download of the same item in another tab to finish or fail.
  return navigator.locks.request(`media:${key}`, async () => removeFiles(await mediaDir(), key));
}

async function get({ url }) {
  const key = await keyFor(url);
  const meta = await readMeta(await mediaDir(), key);
  return meta ? { key, ...meta } : null;
}

async function list() {
  const dir = await mediaDir();
  const items = [];
  for await (const [name, handle] of dir.entries()) {
    if (handle.kind !== "file" || !name.endsWith(".json")) continue;
    const key = name.slice(0, -".json".length);
    const meta = await readMeta(dir, key);
    if (meta?.complete) items.push({ key, ...meta });
  }
  return items;
}

const handlers = { download, remove, get, list };

self.onmessage = async ({ data: { id, type, payload = {} } }) => {
  if (type === "abort") {
    jobs.get(payload.id)?.controller.abort(); // the download then rejects with reason "aborted"
    return;
  }
  try {
    if (!Object.hasOwn(handlers, type)) throw fail("failed", `Unknown request type "${type}"`);
    self.postMessage({ id, ok: true, result: await handlers[type](payload, id) });
  } catch (error) {
    const reason = error.reason ?? REASONS[error.name] ?? "failed";
    self.postMessage({ id, ok: false, error: { name: error.name, message: error.message, reason } });
  }
};

Every failure reaches the page as a reason it can act on:

Error Raised by reason What the store does
QuotaExceededError write() or truncate() on a sync handle, or the estimate() pre-flight quota Closes the handles and deletes both files
NoModificationAllowedError createSyncAccessHandle() or removeEntry() while code outside the Web Lock holds the file locked Changes nothing. See Debugging OPFS to find the holder
AbortError The signal, while waiting for the lock, in fetch() or during a read aborted Keeps the partial file and its durable offset, so the next call resumes
TypeError, HTTP errors, short bodies fetch(), stream reads, the size check network Keeps the partial file for resume
SecurityError getDirectory() without storage access unavailable Nothing to clean up. Offer streaming only

The page API: media-store.js

persist() is [Exposed=Window], so the persistence request lives in the page. estimate() and persisted() work in workers too.

media-store.js
// Promise-based page API for media-worker.js.
const worker = new Worker(new URL("./media-worker.js", import.meta.url), { type: "module" });
const pending = new Map(); // id -> { resolve, reject, onProgress }
let nextId = 1;

export class MediaStoreError extends Error {
  constructor({ name, message, reason }) {
    super(message);
    this.name = name;
    this.reason = reason; // "quota" | "locked" | "aborted" | "network" | "unavailable" | "failed"
  }
}

worker.onmessage = ({ data }) => {
  const request = pending.get(data.id);
  if (!request) return;
  if (data.type === "progress") {
    request.onProgress?.({ received: data.received, total: data.total });
    return;
  }
  pending.delete(data.id);
  if (data.ok) request.resolve(data.result);
  else request.reject(new MediaStoreError(data.error));
};

worker.onerror = (event) => {
  // The worker failed to load or threw outside a request: fail everything in flight.
  const message = event.message || "Media worker failed";
  for (const { reject } of pending.values()) reject(new MediaStoreError({ name: "Error", message, reason: "failed" }));
  pending.clear();
};

function call(type, payload, { onProgress, signal } = {}) {
  if (signal?.aborted) {
    return Promise.reject(new MediaStoreError({ name: "AbortError", message: "Aborted", reason: "aborted" }));
  }
  const id = nextId++;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject, onProgress });
    worker.postMessage({ id, type, payload });
    // The worker stops, keeps the partial file for resume and rejects with reason "aborted".
    signal?.addEventListener("abort", () => worker.postMessage({ type: "abort", payload: { id } }), { once: true });
  });
}

/** The page and the worker must agree on the exact URL string, because the file name is its hash. */
function normalize(url) {
  const parsed = new URL(url, location.href);
  parsed.hash = ""; // fragments never reach the server or the service worker
  return parsed.href;
}

let persistence = null;

/** Asks once. Call it from a user action: Firefox shows a prompt, other engines decide silently. */
export function requestPersistence() {
  persistence ??= (async () => {
    if (typeof navigator.storage?.persist !== "function") return false;
    return (await navigator.storage.persisted()) || (await navigator.storage.persist());
  })().catch(() => false);
  return persistence;
}

export async function saveForOffline(url, { type, onProgress, signal } = {}) {
  const persisted = await requestPersistence(); // best effort: a refusal doesn't block the download
  const item = await call("download", { url: normalize(url), type }, { onProgress, signal });
  return { ...item, persisted };
}

export const removeOffline = (url) => call("remove", { url: normalize(url) });
export const listOffline = () => call("list");

/** The URL for <audio> or <video>: the service worker route when stored, the network otherwise. */
export async function playbackUrl(url) {
  const item = await call("get", { url: normalize(url) }).catch(() => null);
  return item?.complete ? `/offline-media/${item.key}` : url;
}

Wiring it to a download button:

episode-download.js
import { playbackUrl, saveForOffline } from "./media-store.js";

const MESSAGES = {
  quota: "Not enough storage. Remove some downloads and try again.",
  aborted: "Download paused. Download again to resume.",
  locked: "This episode is busy in another window. Try again shortly.",
  network: "Connection lost. Download again to continue where it stopped.",
  unavailable: "Offline downloads aren't available in this browser mode.",
};
let controller = null;

downloadButton.addEventListener("click", async () => {
  controller = new AbortController();
  try {
    const item = await saveForOffline(episode.audioUrl, {
      signal: controller.signal,
      onProgress: ({ received, total }) => renderProgress(received, total), // total is null if unknown
    });
    if (!item.persisted) showNotice("Saved. The browser may still remove downloads when storage runs low.");
    player.src = await playbackUrl(episode.audioUrl);
  } catch (error) {
    showNotice(MESSAGES[error.reason] ?? "Download failed.");
  }
});

pauseButton.addEventListener("click", () => controller?.abort());

Serving stored media from the service worker

The service worker reads with async handles, which work in every worker type. getFile() returns a disk-backed File, and new Response(file.slice(start, end)) streams from disk, so seeking in a 2 GB video never loads it into memory. sw-range.js holds the parseRange() function from Range requests and media, unchanged.

sw.js (offline media route)
importScripts("/sw-range.js"); // defines parseRange(header, size)

const OFFLINE_MEDIA = "/offline-media/";
const MEDIA_TYPE = /^(audio|video)\/[\w.+-]+$/;

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);
  if (url.origin !== self.location.origin || !url.pathname.startsWith(OFFLINE_MEDIA)) return;
  if (request.method !== "GET" || request.mode === "navigate") return; // never render stored bytes as a page
  event.respondWith(serveOfflineMedia(request, url.pathname.slice(OFFLINE_MEDIA.length)));
});

/** Resolves with { file, meta } for a complete item, or null. Only lock-free async reads. */
async function openStored(key) {
  if (!/^[0-9a-f]{64}$/.test(key)) return null; // only hashes produced by the media worker
  const root = await navigator.storage.getDirectory();
  const dir = await root.getDirectoryHandle("media");
  const metaFile = await (await dir.getFileHandle(`${key}.json`)).getFile();
  const meta = JSON.parse(await metaFile.text());
  if (!meta.complete) return null;
  const file = await (await dir.getFileHandle(`${key}.bin`)).getFile(); // lazy snapshot, nothing read yet
  return file.size === meta.size ? { file, meta } : null;
}

async function serveOfflineMedia(request, key) {
  let stored = null;
  try {
    stored = await openStored(key);
  } catch (error) {
    // NotFoundError: deleted or evicted. SyntaxError: sidecar mid-rewrite. SecurityError: storage blocked.
    console.warn(`Offline media ${key} unavailable: ${error.name}`);
  }
  if (!stored) {
    return new Response("Not available offline", { status: 404, headers: { "Content-Type": "text/plain" } });
  }

  const { file, meta } = stored;
  const headers = {
    "Content-Type": MEDIA_TYPE.test(meta.type) ? meta.type : "application/octet-stream",
    "X-Content-Type-Options": "nosniff",
    "Accept-Ranges": "bytes",
  };
  const rangeHeader = request.headers.get("Range");
  const range = rangeHeader ? parseRange(rangeHeader, file.size) : null;
  if (range === "unsatisfiable") {
    return new Response(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${file.size}` } });
  }
  if (range === null) {
    return new Response(file, { headers: { ...headers, "Content-Length": String(file.size) } });
  }
  const body = file.slice(range.start, range.end + 1); // HTTP byte ranges are inclusive
  return new Response(body, {
    status: 206,
    statusText: "Partial Content",
    headers: {
      ...headers,
      "Content-Range": `bytes ${range.start}-${range.end}/${file.size}`,
      "Content-Length": String(body.size),
    },
  });
}

Don't serve downloaded bytes with their original Content-Type

The route serves third-party bytes from your own origin. If it passed through a text/html or image/svg+xml type from the server, and let navigations through, any URL the app downloads could run script as your origin. The route refuses navigations and allows only audio/* and video/* types.

If the worker deletes an item while it plays, the File snapshot becomes unreadable and the media element fires error. Handle that event by switching src back to the network URL.

Adapting the example

  • Cross-origin media needs CORS. The worker needs a readable body, not an opaque response. For resume, the server must also allow the Range and If-Range request headers and send Access-Control-Expose-Headers: Content-Range, ETag, because neither is a CORS-safelisted response header. Without the exposed headers, the worker can't verify a partial response and restarts from zero instead of resuming.
  • Compressed responses break the size check. Media is normally served without Content-Encoding. If yours isn't, Content-Length describes the encoded bytes, so drop the size comparison.
  • Downloads stop with the page. The worker dies when its last tab closes, and the next saveForOffline() call resumes. For downloads that continue in the background, Background Fetch (Chromium only) can fetch the file, and the backgroundfetchsuccess handler can stream each record into OPFS with createWritable() under the same Web Lock.
  • Query metadata in IndexedDB. Once you need sorting, per-show listings or last-played positions, keep the sidecar as the commit marker and mirror the metadata into IndexedDB.

Browser support

Support data as of September 2026. See MDN's File System API compatibility data and caniuse for live data.

Feature Chrome / Edge desktop Chrome Android Firefox Safari macOS / iOS
navigator.storage.getDirectory(), file and directory handles, removeEntry(), resolve() ✅ 86 ✅ 109 ✅ 111 ✅ 15.2
FileSystemFileHandle.getFile() ✅ 86 ✅ 109 ✅ 111 ⚠️ 15.2 / 15.4
entries(), keys(), values() ✅ 86 ✅ 109 ✅ 111 ✅ 15.2
for await directly on a directory handle ✅ 86 ✅ 109 ✅ 111 ✅ 16.4
createSyncAccessHandle() (dedicated workers) ✅ 102 ✅ 109 ✅ 111 ✅ 15.2
Synchronous getSize(), truncate(), flush(), close() ✅ 108 ✅ 109 ✅ 111 ✅ 16.4
createWritable() / FileSystemWritableFileStream ✅ 86 ✅ 109 ✅ 111 ✅ 26
move() (non-standard) ⚠️ 102 ⚠️ 109 ✅ 111 ✅ 15.2
remove() (non-standard) ✅ 110 ✅ 110 ❌ ❌
createSyncAccessHandle({ mode }) (non-standard) ✅ 121 ✅ 121 ❌ ❌
createWritable({ mode }) (non-standard) ✅ 121 ✅ 121 ❌ ❌
FileSystemObserver (non-standard) ✅ 133 ❌ ❌ ❌
navigator.storage.estimate() (used by the complete example) ✅ 61 ✅ 61 ✅ 57 ✅ 17
Atomics.waitAsync() (needed by SQLite's opfs-wl) ✅ 90 ✅ 90 ✅ 145 ✅ 16.4

⚠️ Chromium implements move() on FileSystemFileHandle only, so directories can't be moved. For getFile() in Safari, MDN's compatibility data lists 15.2, while WebKit's announcement says it was introduced in Safari on macOS 12.4 and iOS 15.4. Either way, every Safari version you're likely to support today has it.

The non-standard rows are exactly that: shipped in one engine, not agreed in the standard. FileSystemObserver notifies you of changes to observed files and directories, OPFS included, with records of type "appeared", "disappeared", "modified", "moved", "unknown" and "errored". It shipped in Chrome 133 on desktop after an origin trial from Chrome 129 (Chrome for Developers). Use it only as a progressive enhancement over your own BroadcastChannel notifications.

Debugging OPFS

OPFS contents are invisible to the user and, in most browsers, to the built-in developer tools too:

  • Chromium DevTools has no OPFS browser in the Application panel. The web.dev OPFS article points to crbug.com/1284595 for built-in support. Application › Storage still shows usage by type, clears site data, and can simulate a custom storage quota, which is the easiest way to make the worker's QuotaExceededError path run. See Browser DevTools.
  • OPFS Explorer, a Chrome extension by Thomas Steiner, adds an OPFS Explorer tab to DevTools. It shows the file tree, downloads a file when you click it, and deletes entries. Deleting a file that a sync access handle holds fails, and deleting files under a running app can break it.
  • Any browser's console can print the tree with the helper below. Every context of the origin sees the same OPFS root, so a page's console is enough. To test worker-only code such as createSyncAccessHandle(), pick the worker in the Chromium console's JavaScript context selector.
opfs-debug.js
// Paste into the console of any page or worker on the origin.
async function opfsTree(dir, path = "") {
  dir ??= await navigator.storage.getDirectory();
  const rows = [];
  for await (const [name, handle] of dir.entries()) {
    const childPath = `${path}/${name}`;
    if (handle.kind === "directory") {
      rows.push({ path: `${childPath}/` }, ...(await opfsTree(handle, childPath)));
      continue;
    }
    try {
      const file = await handle.getFile();
      rows.push({ path: childPath, bytes: file.size, modified: new Date(file.lastModified).toISOString() });
    } catch (error) {
      rows.push({ path: childPath, error: error.name }); // e.g. deleted during the walk
    }
  }
  return rows;
}

/** Deletes everything in this origin's OPFS. Fails for files held open by a sync handle. */
async function opfsClear() {
  const root = await navigator.storage.getDirectory();
  const names = [];
  for await (const name of root.keys()) names.push(name); // don't mutate while iterating
  for (const name of names) await root.removeEntry(name, { recursive: true });
}

console.table(await opfsTree());
console.log(await navigator.locks.query()); // { held, pending }: Web Lock names and client IDs

When createSyncAccessHandle() or removeEntry() fails with NoModificationAllowedError, something else holds the file:

  1. Check the Web Locks. navigator.locks.query() lists held and pending locks with the clientId of each holder. A held media:<key> or sqlite:opfs-sahpool lock identifies the client that owns the file, and pending shows who is waiting for it.
  2. Look for leaked handles. A handle that was opened but not closed, because an exception skipped close(), keeps its lock until the worker ends. Close handles in finally blocks, as every example on this page does.
  3. Look for other tabs and tools. A second tab of the app, an old worker version that doesn't use your lock names, or OPFS Explorer can hold a file. Closing the tab that runs the worker releases its handles.
  4. Check the context. createSyncAccessHandle being undefined means the code isn't running in a dedicated worker. SecurityError from getDirectory() means storage is blocked in that context, for example by the browser's privacy settings.

For quota problems, log await navigator.storage.estimate() before and after a download, and read Storage Quotas & Persistence for per-browser limits and eviction. For SQLite files, the pool VFS's exportFile() gives you a copy you can open with the sqlite3 command-line tool.

Further reading

On this site

External references