Back
Engineering

OPFS: The File System the Web Always Deserved

March 22, 202611 min read
OPFSWeb APIsPerformance

Every web storage API before OPFS felt like a compromise. localStorage gave you 5MB of synchronous string storage that blocks the main thread. IndexedDB gave you structured data with an API so painful that entire libraries exist just to make it tolerable. The Cache API was great — if your data happened to look like HTTP responses.

Then the Origin Private File System arrived, and for the first time, the browser had a storage primitive that actually behaves like a file system. Directories. Files. Byte-level random access. Synchronous I/O in workers. No permission prompts. No user-visible files. Just a fast, private, origin-scoped file system that lets web apps do things that were previously impossible.

This post covers what OPFS is, why the synchronous access handle changes everything, how SQLite in the browser actually works, and when you should (and should not) reach for it.

Why Existing Storage APIs Fall Short

Before we look at OPFS, it helps to understand what gap it fills:

APIData ModelSweet SpotPain Point
localStorageKey-value stringsTiny config data5MB cap, blocks main thread, strings only
IndexedDBStructured objectsMedium structured dataVerbose API, no byte-level access, slow for binary
Cache APIHTTP request/response pairsService worker cachingOnly models network responses
OPFSReal file systemLarge binary data, databases, file I/OWorker-only for best performance

The critical gap was byte-level random access. If you need to read 512 bytes at offset 4096 in a file — which is exactly what a database engine does thousands of times per second — none of the existing APIs could do that efficiently. IndexedDB forces you to read and write entire blobs. localStorage is not even in the conversation.

OPFS fills that gap completely.

Getting Started: The Basics

The entry point is navigator.storage.getDirectory(), which returns a handle to your origin's private root directory:

typescript
// Get the OPFS root directory
const root = await navigator.storage.getDirectory();

// Create directories
const projectDir = await root.getDirectoryHandle('projects', { create: true });
const docsDir = await projectDir.getDirectoryHandle('docs', { create: true });

// Create and write a file
const fileHandle = await root.getFileHandle('config.json', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify({ theme: 'dark', lang: 'en' }));
await writable.close(); // MUST close — data is lost otherwise

// Read it back
const file = await fileHandle.getFile();
const config = JSON.parse(await file.text());
console.log(config.theme); // 'dark'

// List directory contents
for await (const [name, handle] of root) {
  console.log(name, handle.kind); // 'file' or 'directory'
}

// Clean up
await root.removeEntry('config.json');
await root.removeEntry('projects', { recursive: true });

This async API works on the main thread. It is fine for simple read/write operations — config files, user preferences, small documents. But it has a critical limitation: createWritable() uses copy-on-write semantics. The entire file is copied, modified, and swapped back. For a 100MB database file where you need to update a single 4KB page, that is catastrophically inefficient.

This is where the real power of OPFS lives.

The Sync Access Handle: Where Everything Changes

FileSystemSyncAccessHandle is the API that makes OPFS revolutionary. It provides truly synchronous, byte-level, in-place file I/O — but only in Web Workers:

typescript
// worker.ts — synchronous file I/O
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('data.bin', { create: true });
const handle = await fileHandle.createSyncAccessHandle();

// All of these are SYNCHRONOUS — no await
const encoder = new TextEncoder();
const data = encoder.encode('Hello from OPFS');

handle.write(data, { at: 0 });            // write at offset 0
handle.flush();                            // fsync — ensure durability

const readBuf = new Uint8Array(15);
handle.read(readBuf, { at: 0 });           // read from offset 0
console.log(new TextDecoder().decode(readBuf)); // "Hello from OPFS"

console.log(handle.getSize());             // 15
handle.truncate(1024);                     // resize the file
handle.close();                            // release the exclusive lock

Notice something crucial: no await on the I/O operations. These are truly synchronous calls — read(), write(), flush(), truncate(), getSize(), close(). They block the worker thread (not the main thread) and return immediately with results.

This might seem like a minor API detail, but it is the single most important design decision in OPFS. Here is why.

Why Synchronous I/O Matters: The WebAssembly Connection

C, C++, and Rust libraries compiled to WebAssembly expect POSIX-style synchronous file I/O — read(), write(), lseek(), fsync(). You cannot easily make a C library work with JavaScript Promises. The call stack does not work that way.

Before OPFS, running something like SQLite in the browser meant one of two painful options:

  1. MEMFS (in-memory): Fast, but all data is lost when the page closes. Not a real database.
  2. IndexedDB backend with Asyncify: Emscripten's Asyncify rewrites the Wasm binary to support pausing and resuming the call stack across async boundaries. It works, but it is slow, bloats the binary, and introduces subtle bugs.

The sync access handle eliminates both problems. SQLite's VFS (Virtual File System) layer can call handle.read() and handle.write() directly — no Asyncify, no memory-only limitation, no compromises.

SQLite in the Browser: The Killer App

This is the use case that drove OPFS adoption. The official SQLite project now ships a Wasm build that recommends OPFS as its persistence backend. Here is how the architecture works:

Your main thread sends SQL queries to a worker via postMessage. The worker runs SQLite's Wasm module, which reads and writes database pages through the sync access handle. The database persists across page loads, survives browser restarts, and performs at near-native speed.

Libraries like wa-sqlite, sql.js, and PGlite (Postgres compiled to Wasm) all use this pattern. The performance improvement over IndexedDB-backed SQLite is dramatic — roughly 5-10x faster for typical workloads, because:

  • In-place writes: The sync handle modifies file bytes directly, no copy-on-write overhead
  • No serialization: IndexedDB requires serializing data to structured cloneable objects; OPFS deals in raw bytes
  • Lower transaction cost: handle.flush() is cheaper than committing an IndexedDB transaction
  • Random access: Reading a 4KB database page at offset 8192 is a single handle.read() call, not "deserialize a blob and slice it"

This is not a theoretical improvement. It is the difference between a web app that feels sluggish and one that feels native.

Real-World Use Cases Beyond SQLite

OPFS unlocks several categories of web applications that were previously impractical:

Offline-first document editors: Store user documents (drawings, spreadsheets, rich text) as actual files with efficient incremental saves. Instead of serializing an entire document to IndexedDB on every keystroke, write only the changed bytes.

Media processing pipelines: Video and audio editors can store intermediate files during processing — trimmed clips, decoded frames, mixed audio tracks — without holding everything in memory.

Git in the browser: Projects like isomorphic-git can use OPFS as a real file system backend instead of shimming everything through IndexedDB or in-memory storage.

Game save data: Binary save files that games read and write with the same byte-level patterns they use natively. No more serializing game state to JSON.

Development tools: Browser-based IDEs (like StackBlitz or CodeSandbox) can store project files with the same performance characteristics as a local file system.

The Security Model: Private by Design

OPFS has a deliberately restrictive security model:

  • Origin-scoped: Each origin (scheme + host + port) gets its own isolated file system. https://example.com cannot access files from https://other.com.
  • Invisible to users: Unlike showOpenFilePicker(), OPFS files are not visible in Finder or Explorer. Users cannot browse to them. The browser stores them in an opaque internal format.
  • No permission prompts: Accessing OPFS requires no user gesture or permission dialog — it is available immediately.
  • Secure context required: HTTPS only (plus localhost for development).
  • Storage eviction: Without calling navigator.storage.persist(), the browser may evict OPFS data under storage pressure. Always persist for important data:
typescript
// Request persistent storage — prevents automatic eviction
const persisted = await navigator.storage.persist();
if (persisted) {
  console.log('Storage will not be evicted');
}

// Check available quota
const estimate = await navigator.storage.estimate();
console.log(`Using ${estimate.usage} of ${estimate.quota} bytes`);

Browser Support: It Is Production-Ready

As of 2026, OPFS has solid cross-browser support:

BrowsergetDirectory()Sync Access Handle
Chrome / Edge86+102+
Firefox111+111+
Safari / iOS15.2+15.2+

This is not bleeding-edge technology. All three major engines ship full OPFS support including synchronous access handles. The main risk is users on very old browser versions, which you are likely already handling with other feature requirements.

One caveat: Safari versions before 16.4 had bugs with flush() behavior and file locking. If you are targeting iOS users, test on real Safari and handle edge cases defensively.

Gotchas and Limitations

OPFS is powerful, but it has sharp edges:

1. Exclusive locking on sync handles. Only one createSyncAccessHandle() can exist per file across all tabs and workers for an origin. If another tab's worker has the handle open, your call will throw. You need to architect around single-writer patterns — typically by routing all file access through a single worker.

2. No file watching. There is no equivalent of fs.watch(). If another tab modifies a file via the async API while you are not holding the sync lock, you will not be notified. You need your own coordination mechanism.

3. Debugging is painful. There is no polished DevTools panel for browsing OPFS contents. Chrome has experimental support under Application > Storage, but you will likely need to write your own debug utility:

typescript
// Quick OPFS directory listing utility
async function listOPFS(dir?: FileSystemDirectoryHandle, path = '/') {
  const root = dir ?? await navigator.storage.getDirectory();
  const entries: string[] = [];
  for await (const [name, handle] of root) {
    const fullPath = path + name;
    if (handle.kind === 'directory') {
      entries.push(fullPath + '/');
      entries.push(...await listOPFS(handle, fullPath + '/'));
    } else {
      const file = await (handle as FileSystemFileHandle).getFile();
      entries.push(fullPath + ' (' + file.size + ' bytes)');
    }
  }
  return entries;
}

4. Shared quota. OPFS does not get its own storage quota. It shares with IndexedDB, Cache API, and everything else for the origin. A large OPFS usage can push toward eviction thresholds if you have not called persist().

5. No cross-origin sharing. There is no way to share OPFS files between origins, even cooperating ones. Each origin is fully isolated.

6. Minimal filesystem features. No symlinks, no file permissions, no custom metadata, no timestamps beyond what getFile() returns. This is a minimal file system, not a full POSIX implementation.

When to Use OPFS (and When Not To)

Use OPFS when:

  • You need a client-side database (SQLite, PGlite) with persistence
  • Your app processes large binary files (media, 3D models, scientific data)
  • You need byte-level random access to stored data
  • You are building offline-first applications that need efficient incremental saves
  • You have Wasm modules that expect synchronous file I/O

Stick with IndexedDB when:

  • You are storing structured JavaScript objects with indexes and queries
  • Your data model maps cleanly to key-value or document storage
  • You do not need byte-level access
  • You want simpler code without the Worker architecture

Stick with localStorage when:

  • You are storing a few kilobytes of configuration
  • You need the simplest possible API
  • You are okay with the 5MB limit and main-thread blocking

The Bigger Picture

OPFS is not just another storage API. It is a signal that the web platform is serious about competing with native applications for data-intensive workloads. The fact that the official SQLite project invested in OPFS support — that they trust it enough to recommend it as the primary persistence layer for SQLite Wasm — says more than any benchmark.

The mental model shift is real. Web developers are used to thinking in key-value stores and document databases. OPFS asks you to think in files and bytes. That is closer to systems programming, and it unlocks the same kind of applications that systems programmers build.

We are living in the era where "you can't build that in a browser" is increasingly wrong. OPFS is one of the reasons why.