Back to Blog
Feb 3, 20264 min readOnuzulike Anthony

OPFS: The Browser's Private File System for Offline Apps

Writing and reading large files, HLS segment storage, IndexedDB for metadata, and quota management in the Origin Private File System.

Full-StackOPFSPWAOffline

The Origin Private File System (OPFS) is a browser API that gives web apps a sandboxed, high-performance file system. Unlike IndexedDB (which is great for structured data but awkward for binary blobs) or the Cache API (great for HTTP responses but limited control), OPFS is designed for large binary files — exactly what you need when storing downloaded video segments for offline playback.

Why OPFS Over IndexedDB for Media

IndexedDB stores blobs as JavaScript values. For a 2 GB movie download broken into 300 HLS segments, this creates serious performance problems — the blob metadata still lives in memory, and large reads block the main thread.

OPFS gives you:

  • Native file handles — read/write via FileSystemSyncAccessHandle in a Worker (no main thread blocking)
  • Streaming reads — read chunks without loading the full file
  • Persistence — survives page reloads, distinct from the Cache API
  • No size limits in practice — quota is managed by the browser per origin

Getting a File Handle

ts
async function getDownloadDir(): Promise<FileSystemDirectoryHandle> {
  const root = await navigator.storage.getDirectory();
  return root.getDirectoryHandle("downloads", { create: true });
}
 
async function writeSegment(jobId: string, segmentIndex: number, data: ArrayBuffer) {
  const dir = await getDownloadDir();
  const jobDir = await dir.getDirectoryHandle(jobId, { create: true });
  const file = await jobDir.getFileHandle(`seg-${segmentIndex}.ts`, { create: true });
 
  // Use sync access handle in a Worker for best performance
  const accessHandle = await file.createSyncAccessHandle();
  accessHandle.write(data);
  accessHandle.flush();
  accessHandle.close();
}

The createSyncAccessHandle() method is only available inside a Web Worker or Service Worker — it blocks synchronously, which is fine off the main thread and dramatically faster than the async alternative.

Reading Segments Back

ts
async function readSegment(jobId: string, segmentIndex: number): Promise<ArrayBuffer> {
  const dir = await getDownloadDir();
  const jobDir = await dir.getDirectoryHandle(jobId);
  const fileHandle = await jobDir.getFileHandle(`seg-${segmentIndex}.ts`);
  const file = await fileHandle.getFile();
  return file.arrayBuffer();
}

For streaming playback, return a ReadableStream instead:

ts
async function getSegmentStream(jobId: string, index: number): Promise<ReadableStream> {
  const fileHandle = await (await getJobDir(jobId)).getFileHandle(`seg-${index}.ts`);
  const file = await fileHandle.getFile();
  return file.stream();
}

Pairing OPFS with IndexedDB

OPFS is purely a byte store — it has no metadata. Use IndexedDB to track what you have:

ts
// Store metadata in IndexedDB
interface DownloadRecord {
  jobId: string;
  tmdbId: number;
  title: string;
  totalSegments: number;
  downloadedSegments: number;
  status: "downloading" | "complete" | "paused" | "error";
  expiresAt: number;
  fileSizeBytes: number;
}
 
async function upsertRecord(record: DownloadRecord) {
  const db = await openIDB();
  const tx = db.transaction("downloads", "readwrite");
  await tx.objectStore("downloads").put(record);
}

When you list downloads or show progress, query IndexedDB. When you need to play back or resume, use OPFS.

Checking Quota

ts
async function getStorageQuota() {
  const estimate = await navigator.storage.estimate();
  return {
    usedGB: (estimate.usage ?? 0) / 1e9,
    quotaGB: (estimate.quota ?? 0) / 1e9,
    percentUsed: ((estimate.usage ?? 0) / (estimate.quota ?? 1)) * 100,
  };
}

Browsers typically grant 60% of available disk space as quota for OPFS. On mobile, this can be as low as a few GB. Check before starting a large download and warn the user.

Cleaning Up

ts
async function deleteDownload(jobId: string) {
  const root = await navigator.storage.getDirectory();
  const dir = await root.getDirectoryHandle("downloads");
  await dir.removeEntry(jobId, { recursive: true });
}
 
async function deleteAllDownloads() {
  const root = await navigator.storage.getDirectory();
  const dir = await root.getDirectoryHandle("downloads");
  for await (const [name] of dir.entries()) {
    await dir.removeEntry(name, { recursive: true });
  }
}

HLS Segment Pipeline Summary

The full download flow in NodWatch:

  1. Fetch M3U8 playlist from the server proxy
  2. Parse segment URLs from the manifest
  3. For each segment: download with fetch() in parallel batches, write to OPFS via Worker
  4. Store progress in IndexedDB after each successful segment
  5. On completion: store final manifest in IndexedDB, mark record as complete
  6. For playback: reconstruct M3U8 from stored metadata, serve segments from OPFS via a custom fetch handler in the service worker, hand to <video> element

The service worker intercepts segment requests and serves from OPFS — the video element has no idea it's reading from local storage.