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.
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
FileSystemSyncAccessHandlein 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
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
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:
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:
// 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
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
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:
- Fetch M3U8 playlist from the server proxy
- Parse segment URLs from the manifest
- For each segment: download with
fetch()in parallel batches, write to OPFS via Worker - Store progress in IndexedDB after each successful segment
- On completion: store final manifest in IndexedDB, mark record as
complete - For playback: reconstruct M3U8 from stored metadata, serve segments from OPFS via a custom
fetchhandler 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.