Why the origin private file system exists

Traditional web file workflows are built around the user-visible file system. The user uploads a file, edits it, and downloads a copy. With the File System Access API, this flow improved significantly: showOpenFilePicker(), showSaveFilePicker(), and showDirectoryPicker() let users work with real files and folders rather than copies.

Yet these APIs sit on top of a slow, security-heavy path. Files obtained from the web carry the mark of the web, pass through Safe Browsing checks, and writes use temporary files rather than in-place updates. Every write() call to a FileSystemWritableFileStream is self-contained, opening the file, seeking to an offset, and writing data. That is acceptable for user-facing file operations, but not for performance-critical data processing.

Files themselves remain a great way to store structured data: SQLite holds an entire database in one file, and image processing makes heavy use of mipmaps, pre-calculated sequences of progressively lower-resolution images. The origin private file system (OPFS) brings the benefits of file-based storage to the web without the costs of the user-visible file system's security pipeline.

What the origin private file system is

As specified in the File System Living Standard, OPFS is a storage endpoint private to the page's origin. Unlike files and folders you interact with through the OS file explorer, OPFS data is not user-visible. It is private to the origin of the site, so https://developer.chrome.com and https://web.dev each have completely separate OPFS instances. Pages sharing the same origin see the same OPFS data.

OPFS is browser storage in the same category as localStorage and IndexedDB. It is subject to quota restrictions, and clearing all browsing data or site data deletes it. Track storage consumption with navigator.storage.estimate(), checking the usage field for total usage and the usageDetails object's fileSystem entry for OPFS-specific usage. Because OPFS is invisible to users, there are no permission prompts and no Safe Browsing checks.

The entry point is the root directory, obtained by calling:

const opfsRoot = await navigator.storage.getDirectory();
// A FileSystemDirectoryHandle whose type is "directory"
// and whose name is "".
console.log(opfsRoot);

The result is an initially empty FileSystemDirectoryHandle. From there, OPFS works conceptually like the user-visible file system with a hierarchical file and folder structure, differing only in the root:

Diagram of the user-visible file system and the origin private file system with two exemplary file hierarchies.
The entry point for the user-visible file system is a symbolic hard disk, the entry point for the origin private file system is calling of the method navigator.storage.getDirectory.

Synchronous APIs in Web Workers

OPFS can be used on the main thread or in a Web Worker. Web Workers cannot block the main thread, so they allow synchronous APIs, a pattern disallowed on the main thread. Synchronous file operations avoid promise overhead and can be faster; they also match the synchronous file I/O model of languages like C that compile to WebAssembly.

For the fastest possible file operations or WebAssembly workloads, the synchronous OPFS interface is the right choice:

// This is synchronous C code.
FILE *f;
f = fopen("example.txt", "w+");
fputs("Some text\n", f);
fclose(f);

Reading and writing files

Once you hold a FileSystemDirectoryHandle for the root of the origin private file system, you can create files and subfolders with getFileHandle() and getDirectoryHandle(), respectively. Pass {create: true} to both methods when the target doesn't exist yet and you want it created.

const fileHandle = await opfsRoot
    .getFileHandle('my first file', {create: true});
const directoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder', {create: true});
const nestedFileHandle = await directoryHandle
    .getFileHandle('my first nested file', {create: true});
const nestedDirectoryHandle = await directoryHandle
    .getDirectoryHandle('my first nested folder', {create: true});

To open something you created earlier, call the same methods with just the name—no create flag needed:

const existingFileHandle = await opfsRoot.getFileHandle('my first file');
const existingDirectoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder');

A returned FileSystemFileHandle is not itself a File. To read the contents, call getFile() on the handle. The resulting File object is a subtype of Blob, so it works anywhere a Blob does: FileReader, URL.createObjectURL(), createImageBitmap(), and XMLHttpRequest.send() all accept it. Pulling a File out of a handle in this way also makes the data accessible to the user-visible file system.

const file = await fileHandle.getFile();
console.log(await file.text());

For writes, call createWritable() on a file handle to get a FileSystemWritableFileStream. Stream content with write(), then finish with close():

const contents = 'Some text';
// Get a writable stream.
const writable = await fileHandle.createWritable();
// Write the contents of the file to the stream.
await writable.write(contents);
// Close the stream, which persists the contents.
await writable.close();

To remove entries, call remove() on the handle itself—pass {recursive: true} to delete a directory and everything beneath it. If you know the target's name and are already holding its parent directory handle, removeEntry() is the more direct option.

await fileHandle.remove();
await directoryHandle.remove({recursive: true});
directoryHandle.removeEntry('my first nested file');

The move() method renames, relocates, or does both at once. Pass a new name, a new parent directory, or both:

// Rename a file.
await fileHandle.move('my first renamed file');
// Move a file to another directory.
await fileHandle.move(nestedDirectoryHandle);
// Move a file to another directory and rename it.
await fileHandle
    .move(nestedDirectoryHandle, 'my first renamed and now nested file');

To locate a handle relative to another directory, use resolve(). Passing the root directory obtained from navigator.storage.getDirectory() as the reference gives you the full path of any file or folder in the origin private file system.

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// `relativePath` is `['my first folder', 'my first nested folder']`.

If you end up with two handles and need to confirm they refer to the same underlying entry, compare them with isSameEntry():

fileHandle.isSameEntry(nestedFileHandle);
// Returns `false`.

Enumerating folder contents

A FileSystemDirectoryHandle is an asynchronous iterable. The natural way to walk its direct children is a for await...of loop, and you can also call entries(), values(), or keys() to get just the parts you need—name-to-handle pairs, only handles, or only names:

for await (let [name, handle] of directoryHandle) {}
for await (let [name, handle] of directoryHandle.entries()) {}
for await (let handle of directoryHandle.values()) {}
for await (let name of directoryHandle.keys()) {}

Recursing into subfolders while staying inside asynchronous iteration is easy to get tangled. This starter function walks the full tree and reports each file with its size. If sizes don't matter, replace the pushed handle.getFile() promise with the plain handle and simplify accordingly.

const getDirectoryEntriesRecursive = async (
  directoryHandle,
  relativePath = '.',
) => {
  const fileHandles = [];
  const directoryHandles = [];
  const entries = {};
  // Get an iterator of the files and folders in the directory.
  const directoryIterator = directoryHandle.values();
  const directoryEntryPromises = [];
  for await (const handle of directoryIterator) {
    const nestedPath = `${relativePath}/${handle.name}`;
    if (handle.kind === 'file') {
      fileHandles.push({ handle, nestedPath });
      directoryEntryPromises.push(
        handle.getFile().then((file) => {
          return {
            name: handle.name,
            kind: handle.kind,
            size: file.size,
            type: file.type,
            lastModified: file.lastModified,
            relativePath: nestedPath,
            handle
          };
        }),
      );
    } else if (handle.kind === 'directory') {
      directoryHandles.push({ handle, nestedPath });
      directoryEntryPromises.push(
        (async () => {
          return {
            name: handle.name,
            kind: handle.kind,
            relativePath: nestedPath,
            entries:
                await getDirectoryEntriesRecursive(handle, nestedPath),
            handle,
          };
        })(),
      );
    }
  }
  const directoryEntries = await Promise.all(directoryEntryPromises);
  directoryEntries.forEach((directoryEntry) => {
    entries[directoryEntry.name] = directoryEntry;
  });
  return entries;
};

Synchronous I/O inside a Web Worker

Because Web Workers are allowed to block their own thread, the origin private file system offers synchronous entry points there. The fastest file operations in a worker start with a FileSystemSyncAccessHandle, created from a plain FileSystemFileHandle via createSyncAccessHandle():

const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

The synchronous handle exposes in-place operations, all of which block the worker thread but none of which block the main thread:

  • getSize(): file size in bytes.
  • write(): writes a buffer into the file, optionally at an offset; returns the number of bytes written so callers can detect partial writes.
  • read(): reads file contents into a buffer, optionally from an offset.
  • truncate(): resizes the file.
  • flush(): persists all pending write() modifications.
  • close(): releases the access handle.

A complete round trip through every method looks like this:

const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle('fast', {create: true});
const accessHandle = await fileHandle.createSyncAccessHandle();

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

// Initialize this variable for the size of the file.
let size;
// The current size of the file, initially `0`.
size = accessHandle.getSize();
// Encode content to write to the file.
const content = textEncoder.encode('Some text');
// Write the content at the beginning of the file.
accessHandle.write(content, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `9` (the length of "Some text").
size = accessHandle.getSize();

// Encode more content to write to the file.
const moreContent = textEncoder.encode('More content');
// Write the content at the end of the file.
accessHandle.write(moreContent, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `21` (the length of
// "Some textMore content").
size = accessHandle.getSize();

// Prepare a data view of the length of the file.
const dataView = new DataView(new ArrayBuffer(size));

// Read the entire file into the data view.
accessHandle.read(dataView);
// Logs `"Some textMore content"`.
console.log(textDecoder.decode(dataView));

// Read starting at offset 9 into the data view.
accessHandle.read(dataView, {at: 9});
// Logs `"More content"`.
console.log(textDecoder.decode(dataView));

// Truncate the file after 4 bytes.
accessHandle.truncate(4);

Copying out of the origin private file system

There's no move operation from the origin private file system to the user-visible file system, but copying is straightforward. Since showSaveFilePicker() is exposed only on the main thread, keep this code out of workers:

// On the main thread, not in the Worker. This assumes
// `fileHandle` is the `FileSystemFileHandle` you obtained
// the `FileSystemSyncAccessHandle` from in the Worker
// thread. Be sure to close the file in the Worker thread first.
const fileHandle = await opfsRoot.getFileHandle('fast');
try {
  // Obtain a file handle to a new file in the user-visible file system
  // with the same name as the file in the origin private file system.
  const saveHandle = await showSaveFilePicker({
    suggestedName: fileHandle.name || ''
  });
  const writable = await saveHandle.createWritable();
  await writable.write(await fileHandle.getFile());
  await writable.close();
} catch (err) {
  console.error(err.name, err.message);
}

Inspection with DevTools

Native DevTools support for inspecting the origin private file system is still on the way (track it at crbug/1284595). Until then, the OPFS Explorer Chrome extension fills the gap—in fact, the file hierarchy shown in the earlier code sample screenshot came straight from that extension.

After installing it, open DevTools and select the OPFS Explorer tab to browse the tree. Clicking a file name saves it to the user-visible file system; the trash icon removes entries.

The OPFS Explorer Chrome DevTools extension in the Chrome Web Store.

A working demo uses the origin private file system as the persistence backend for a SQLite build compiled to WebAssembly. Its source is on GitHub. Note that the embedded iframe version falls back because it is cross-origin; opening the demo in its own tab activates the OPFS path.

Where the standard is headed

The File System Standard has already enabled use cases that the user-visible file system API never covered. All three major browser engines—Apple, Mozilla, and Google—are implementing against the same WHATWG specification. Further evolution happens in the open at the whatwg/fs repository, where issues and pull requests from developers shape API refinements.