Origin Private File System
The Fastest Storage You Have Never Used
The Origin Private File System (OPFS) is the storage API most developers have never heard of, and it changes everything about what is possible in the browser. While IndexedDB gives you a structured database and the Cache API gives you HTTP response storage, OPFS gives you something fundamentally different: raw, byte-level file access — like writing to a file system, but sandboxed per origin and invisible to the user.
The killer feature is createSyncAccessHandle(). In a Web Worker, you get synchronous read/write/flush operations on files with zero async overhead. No promises, no callbacks, no transactions. Just raw byte I/O at speeds 3-4x faster than IndexedDB. This is why Adobe chose OPFS for Photoshop on the Web. It is why SQLite WASM uses OPFS as its persistence layer. It is how you build database-grade performance in the browser.
Think of OPFS as a private USB drive plugged into your browser. It is a real file system — with directories, files, and byte-level read/write — but it is invisible to the user (they cannot browse it in Finder or Explorer), sandboxed to your origin (other sites cannot access it), and managed by the browser's storage quota system. The async API (getFile(), createWritable()) is like accessing the USB drive over a network share. The sync API (createSyncAccessHandle()) is like plugging the drive directly into your motherboard — zero latency, maximum throughput, but only available from a Worker thread (to avoid blocking the main thread).
Getting Started: The Directory Handle
Everything starts with navigator.storage.getDirectory(), which returns a handle to the root directory of your origin's private file system.
const root = await navigator.storage.getDirectory();
const configDir = await root.getDirectoryHandle("config", { create: true });
const dataDir = await root.getDirectoryHandle("data", { create: true });
const configFile = await configDir.getFileHandle("settings.json", { create: true });
const dataFile = await dataDir.getFileHandle("records.bin", { create: true });
Directory Operations
// List directory contents
for await (const [name, handle] of root) {
console.log(name, handle.kind); // "config" "directory" or "data.bin" "file"
}
// Remove a file
await root.removeEntry("old-file.txt");
// Remove a directory and all its contents
await root.removeEntry("temp", { recursive: true });
// Resolve a path from root to a nested handle
const path = await root.resolve(nestedFileHandle);
// Returns ["data", "sub", "file.bin"] or null if not a descendant
The Async API: Main Thread Access
The async API works on the main thread (and in Workers). It uses writable streams for writing and File objects for reading.
Writing Files (Async)
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle("data.json", { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify({ users: [1, 2, 3] }));
await writable.close(); // data is only committed on close
// You can also write at specific positions
const writable2 = await fileHandle.createWritable({ keepExistingData: true });
await writable2.seek(10); // move to byte offset 10
await writable2.write("inserted text");
await writable2.close();
Reading Files (Async)
const fileHandle = await root.getFileHandle("data.json");
const file = await fileHandle.getFile(); // returns a File object (like from an input)
const text = await file.text();
const data = JSON.parse(text);
// For binary data
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
The File object returned by getFile() is a snapshot — it reflects the file's contents at the moment you called getFile(). If the file is modified after you get the File but before you read it, you still get the old data. Always call getFile() immediately before reading.
The Sync API: Worker-Only, Maximum Performance
This is where OPFS shines. createSyncAccessHandle() gives you synchronous read/write/flush access to a file — but only inside a Web Worker (or a dedicated Worker, SharedWorker, or service worker). Synchronous means zero async overhead: no microtask queue scheduling, no promise resolution delays, just direct memory-mapped I/O.
// worker.js
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle("database.bin", { create: true });
const accessHandle = await fileHandle.createSyncAccessHandle();
// Write bytes synchronously
const encoder = new TextEncoder();
const data = encoder.encode("Hello, OPFS!");
const bytesWritten = accessHandle.write(data, { at: 0 });
// Read bytes synchronously
const readBuffer = new Uint8Array(bytesWritten);
const bytesRead = accessHandle.read(readBuffer, { at: 0 });
const decoder = new TextDecoder();
console.log(decoder.decode(readBuffer)); // "Hello, OPFS!"
// Get file size
const size = accessHandle.getSize();
// Truncate file
accessHandle.truncate(0); // clear file
accessHandle.truncate(1024); // resize to 1024 bytes
// Flush to disk (ensures durability)
accessHandle.flush();
// Always close when done
accessHandle.close();
Why Sync Access is Faster
Every IndexedDB operation goes through the async pipeline: your request enters a queue, the browser processes it on a separate thread, the result comes back through a promise resolution on the microtask queue. Even a simple get() has microseconds of scheduling overhead.
OPFS sync access skips all of that. The Worker thread directly reads and writes memory-mapped file regions. For workloads that make thousands of small reads/writes (like a database engine), this difference compounds dramatically.
IndexedDB single read: ~0.5ms (async overhead + IPC + deserialization)
OPFS sync single read: ~0.05ms (direct memory access)
Speedup: ~10x per operation
IndexedDB 10,000 reads: ~5,000ms
OPFS sync 10,000 reads: ~500ms
OPFS + SQLite WASM: A Full Database in the Browser
The most impactful use of OPFS is as the storage backend for SQLite compiled to WebAssembly. The official SQLite project provides a WASM build with an OPFS-backed VFS (Virtual File System) that uses createSyncAccessHandle() for all I/O.
The opfs-sahpool VFS
The opfs-sahpool VFS (introduced in SQLite 3.43) pre-allocates a pool of OPFS files and maps SQLite database pages to them. This avoids the overhead of creating and opening file handles for every database operation.
// worker.js — SQLite WASM with OPFS
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
const sqlite3 = await sqlite3InitModule();
const db = new sqlite3.oo1.OpfsDb("/myapp.db");
db.exec("CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)");
db.exec("INSERT INTO products VALUES (1, 'Widget', 9.99)");
const results = [];
db.exec({
sql: "SELECT * FROM products WHERE price < 20",
callback: (row) => results.push(row),
});
db.close();
Performance: SQLite WASM + OPFS vs Raw IndexedDB
| Operation | IndexedDB | SQLite WASM + OPFS |
|---|---|---|
| Insert 10K rows | ~2,000ms | ~200ms |
| Indexed query (10K rows) | ~50ms | ~5ms |
| Full scan (10K rows) | ~500ms | ~50ms |
| Complex join | Not possible natively | ~10ms |
SQLite brings SQL queries, joins, aggregations, full-text search (FTS5), JSON functions, and ACID transactions — all backed by OPFS's high-performance I/O. For apps that need serious data management (note-taking, project management, CRM, analytics), this combination is the state of the art.
Alternatives to Official SQLite WASM
wa-sqlite offers alternative VFS implementations: IDBBatchAtomicVFS (IndexedDB-backed with Asyncify or JSPI), OriginPrivateVFS (OPFS-backed), and AccessHandlePoolVFS (OPFS with pre-allocated handles, similar to opfs-sahpool). The OriginPrivateVFS supports concurrent reads via a readwrite-unsafe mode.
cr-sqlite extends SQLite with CRDT-based conflict resolution — every table can be made "conflict-free," and changes from different devices merge automatically without conflicts. It builds on SQLite's extension system and compiles to WASM.
PowerSync provides a managed sync layer on top of SQLite WASM. It handles the server-side sync infrastructure, conflict resolution, and provides React hooks for reactive queries.
The ecosystem is maturing rapidly. In 2025-2026, the deprecated Worker1 and Promiser1 APIs in the official SQLite WASM build were replaced with more modern interfaces, and opfs-sahpool became the recommended VFS for production use.
Practical Patterns
Pattern 1: Large File Processing in a Worker
// main.js
const worker = new Worker("processor.js");
worker.postMessage({ action: "process", filename: "dataset.csv" });
worker.onmessage = (event) => {
if (event.data.type === "progress") {
updateProgressBar(event.data.percent);
} else if (event.data.type === "complete") {
displayResults(event.data.results);
}
};
// processor.js (Worker)
self.onmessage = async (event) => {
const { action, filename } = event.data;
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle(filename);
const access = await handle.createSyncAccessHandle();
const size = access.getSize();
const chunkSize = 64 * 1024; // 64KB chunks
let offset = 0;
let results = [];
while (offset < size) {
const buffer = new Uint8Array(Math.min(chunkSize, size - offset));
access.read(buffer, { at: offset });
results.push(processChunk(buffer));
offset += chunkSize;
self.postMessage({
type: "progress",
percent: Math.round((offset / size) * 100),
});
}
access.close();
self.postMessage({ type: "complete", results });
};
Pattern 2: Write-Ahead Log for Crash Recovery
// Worker: append-only write-ahead log
class WriteAheadLog {
constructor(accessHandle) {
this.handle = accessHandle;
this.offset = accessHandle.getSize();
}
append(entry) {
const data = new TextEncoder().encode(JSON.stringify(entry) + "\n");
this.handle.write(data, { at: this.offset });
this.offset += data.byteLength;
this.handle.flush(); // ensure durability
}
readAll() {
const size = this.handle.getSize();
if (size === 0) return [];
const buffer = new Uint8Array(size);
this.handle.read(buffer, { at: 0 });
const text = new TextDecoder().decode(buffer);
return text.trim().split("\n").map(line => JSON.parse(line));
}
truncate() {
this.handle.truncate(0);
this.offset = 0;
}
}
Pattern 3: Storing and Retrieving Binary Assets
async function saveImage(name, blob) {
const root = await navigator.storage.getDirectory();
const imagesDir = await root.getDirectoryHandle("images", { create: true });
const fileHandle = await imagesDir.getFileHandle(name, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
}
async function loadImage(name) {
const root = await navigator.storage.getDirectory();
const imagesDir = await root.getDirectoryHandle("images");
const fileHandle = await imagesDir.getFileHandle(name);
const file = await fileHandle.getFile();
return URL.createObjectURL(file);
}
Browser Support and Gotchas
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
getDirectory() | 86+ | 111+ | 15.2+ |
createWritable() | 86+ | 111+ | 16.4+ |
createSyncAccessHandle() | 102+ | 111+ | 15.2+ (full support late 2025) |
remove() on handles | 110+ | 111+ | 16.4+ |
createSyncAccessHandle() takes an exclusive lock on the file. Only one sync access handle can be open per file at a time, across all Workers. If you try to create a second handle for the same file while one is already open, the promise rejects. Always close handles when you are done — forgetting to close is the number one OPFS bug.
| What developers do | What they should do |
|---|---|
| Forgetting to close sync access handles after use An unclosed sync access handle holds an exclusive lock on the file. Other Workers (or even the same Worker after a restart) cannot open the file until the handle is closed. Since Workers can be terminated abruptly, use try/finally to guarantee cleanup. | Always close access handles in a try/finally block to ensure cleanup even on errors |
| Using OPFS on the main thread for performance-critical operations The async API (createWritable, getFile) has overhead from promise scheduling, stream management, and main-thread I/O serialization. The sync API in a Worker bypasses all of this. For database-like workloads, the difference is 3-10x. | Use createSyncAccessHandle() in a Worker for maximum performance — the async API on the main thread is significantly slower |
| Storing user-facing files in OPFS expecting users to find them OPFS files do not appear in the user's file system. They are sandboxed, private, and managed by the browser. If users need to export or share files, you must explicitly read from OPFS and save via the File System Access API or a download link. | OPFS is invisible to users. Use the File System Access API (showSaveFilePicker) for user-visible files |
| Not handling QuotaExceededError when writing to OPFS OPFS shares the same storage quota as IndexedDB and Cache API. Writing a 2GB file when the quota is nearly full will throw QuotaExceededError. Always handle this gracefully — delete old files, notify the user, or compress data. | Catch QuotaExceededError on write operations and implement cleanup or user notification |
Challenge: Build an OPFS-Backed Key-Value Store
Try to solve it before peeking at the answer.
// Build a simple key-value store backed by OPFS that runs in a Worker.
// Requirements:
// 1. get(key) - returns the value or undefined
// 2. set(key, value) - stores a JSON-serializable value
// 3. delete(key) - removes a key
// 4. All data persists across page reloads
// 5. Use a single file with JSON for simplicity
// 6. Handle the case where the file does not exist yet
//
// Bonus: Can you identify the performance limitation of this approach
// compared to IndexedDB? When would you choose this over IndexedDB?Key Rules
- 1OPFS is a sandboxed, origin-private file system invisible to users. It is NOT for user-facing files — use the File System Access API for that.
- 2createSyncAccessHandle() is Worker-only and provides 3-10x faster I/O than IndexedDB for sequential and random access patterns.
- 3Sync access handles take exclusive locks per file. Only one handle per file across all Workers. Always close handles in try/finally.
- 4OPFS shares the same storage quota as IndexedDB and Cache API. Check navigator.storage.estimate() and handle QuotaExceededError.
- 5The primary production use case for OPFS is as the storage backend for SQLite WASM — giving you a full SQL database in the browser with near-native performance.
- 6Use the async API (createWritable, getFile) on the main thread for simple file operations. Use the sync API in Workers for performance-critical workloads.
- 7Browser support is broad: Chrome 86+, Firefox 111+, Safari 15.2+. Full createSyncAccessHandle support across all major browsers landed with Safari 17.4+ in March 2024.