Skip to main content
Lightning Jar - Web Studio Lightning Jar Wordmark

Keeping Media in the Browser with OPFS

Keeping Media in the Browser with OPFS

Audio is heavy, and the browser forgets. A stereo minute decoded to float samples is 23 MB at 48 kHz, a song in Stem Shovel's Studio can hold up to 64 recordings, and every reload throws the decoded buffers away. Until recently the only thing standing between a reopened song and a fresh download of its audio was the HTTP cache, which is opportunistic, opaque, and poor at the byte-range reads a streaming player makes. We have written about what the platform hands you for playback in a multitrack player in 291 lines and about the failures that make no sound. This post is about the storage side: keeping bytes on the device so a song does not download twice, and keeping a recording safe when the tab dies before the upload lands.

The tool is the Origin Private File System, a per-origin disk the page can read and write without ever showing a file picker. It is fast, and it comes with two rules we had to learn: it can be refused at any moment, and it can be emptied at any moment. Everything below follows from those two rules.

Two Caches, One Shape

Stem Shovel keeps two caches in OPFS, and they share a design.

The first is the Studio's PCM cache (src/lib/audio/pcmCache.ts and src/lib/workers/pcmCache.worker.ts). Every source the Studio decodes is written to disk as one file under stem-shovel/pcm/, keyed by the source id: a 20-byte header (a magic string, then the sample rate, channel count and frame count) followed by each channel's samples whole, as 32-bit floats. Float rather than 16-bit, because the Studio keeps 24-bit sources and an export re-encodes from the decoded buffer; disk is cheap and a narrower copy would degrade the export. A cold source, a reopened song and a recording still uploading all come back from disk instead of the network, and a source on disk can be streamed by a worklet voice without being decoded into memory at all.

The second is the Stem Player's "keep on this device" cache (mediaCache.ts and mediaCache.worker.ts), which stores the compressed AAC rendition of each stem under stem-shovel/media/, grouped by song. This is the cheap half of the idea: the player already starts from those renditions (buffered mode fetches and decodes them whole, streamed mode reads them by byte range), so caching the compressed bytes means a kept song's audio, which is nearly the whole download, never fetches again on that device. Nothing is kept unless the listener asks.

Both caches live in a dedicated worker, and that is not a performance choice. Safari has OPFS and sync access handles inside dedicated workers, but no createWritable. The only way to write a file that works on every browser we ship to is FileSystemSyncAccessHandle, and that handle exists only in a worker. So the page talks to a worker over postMessage, and the worker opens a handle per request and closes it after.

Both keep an index.json beside the files with each file's size and when it was last used (the media cache also records which song a file belongs to). The index is the source of truth about which files are whole: the directory is squared against it on open, and anything the index does not list is deleted.

The Cache Never Throws

The page-side class is a facade with a single promise: no call ever rejects. A read that fails is a miss. A write that fails is a no-op. A worker that does not answer in time is a miss. A worker that cannot load at all turns the cache off for the rest of the page and resolves everything it owed with null. The heart of it is one method:

#ask(payload, transfer, timeoutMs): Promise<PcmCacheReply | null> {
	if (!this.available) return Promise.resolve(null);
	return new Promise((resolve) => {
		const id = this.#nextId++;
		const timer = setTimeout(() => {
			if (!this.#pending.delete(id)) return;
			this.#warn("timed out");
			resolve(null);
		}, timeoutMs);
		this.#pending.set(id, (reply) => {
			clearTimeout(timer);
			if (reply?.error) { this.#warn(reply.error); resolve(null); }
			else resolve(reply);
		});
		try {
			this.#start().postMessage({ ...payload, id }, transfer);
		} catch (e) {
			this.#pending.delete(id);
			clearTimeout(timer);
			this.#warn(e);
			resolve(null);
		}
	});
}

A read waits five seconds before it counts as a miss; a write, a removal or a listing waits thirty, because a write queues behind the writes before it. available checks for Worker and navigator.storage.getDirectory up front, so a test, the server, or a browser with no OPFS never starts a worker. The warning fires once per page and then goes quiet.

The contract has a cost we only noticed later. Because write resolves the same way whether the copy was written or not, the engine cannot trust a resolved write. Before it marks a source as "on disk" and lets the streaming voice play from the copy, it asks the cache for a stat and believes only that. And a buffer read back from disk says nothing about where it came from, so a source the engine holds as a blob (a signed-out song kept in the browser) has to be excluded by name, or it would be marked on disk with no copy there.

A Budget, Not a Promise

Each cache has a ceiling: 2 GB for decoded PCM, which holds about an hour and a half of stereo, and 1 GB for the compressed renditions, which holds some two dozen four-minute songs of eight stems. Neither ceiling is the whole story, because the site's storage quota varies by device and the cache should never crowd out the rest of the site's storage. The budget is the smaller of the two:

export function cacheBudget(limit: number, quota: number): number {
	return quota > 0 ? Math.min(limit, quota / 2) : limit;
}

After every write the worker evicts until the cache fits. The PCM cache evicts by source, least recently used first. The media cache evicts by song, whole, because a song kept in part does not play without a connection anyway, so a partial song is worth nothing to the person who asked to keep it. Either way, a single item larger than the entire budget is not kept at all.

The quota numbers we have seen make the halving mostly academic. navigator.storage.estimate() offered 10.7 GB in headless Chromium on our dev VM, 82.5 GB in Safari on a MacBook Pro, and 41.2 GB on an iPhone, half the Mac's and still far past a 2 GB ceiling. Storage is not the constraint on any of those devices, but the halving is cheap insurance for the device we have not measured.

What the budget cannot protect against is the browser's own housekeeping. Safari's tracking prevention deletes a site's script-written storage, OPFS included, after seven days of Safari use without a visit. That is fine for a cache, and it is the reason every copy in Stem Shovel is a cache and the interface says so: the browser may clear a kept song, and then it downloads again.

What the Disk Measured

Before building the real thing we ran a spike: a Playwright page on the dev server's origin writing and reading a four-minute stereo file in OPFS. In headless Chromium, a worker's sync access handle wrote at 320 MB/s, read 256 KB ranges at 300 MB/s and read the whole file at 785 MB/s. The main thread's getFile().slice().arrayBuffer() read it at 1,284 MB/s and createWritable wrote at 277 MB/s. Rebuilding a four-minute stereo AudioBuffer from the cached samples took 66 ms (the spike's file was 16-bit; the shipped format is float, so the rebuild is a copy rather than a conversion). The conclusion we wrote down: disk reads are not the bottleneck of a cache or of a streamed voice; the budget is.

The one surprise was contention. A write of hundreds of megabytes holds the worker, and a streaming voice that needs a window of a different file every fraction of a second cannot wait behind it. A 160 MB write took 844 ms as one call. Split into 8 MB slices with a yield between them, reads of another file completed during the write at a median of 22 ms and a worst case of 154 ms (the final flush and the rename), well inside the voice's 1.5 second lead. The yield is a MessageChannel message to the worker itself rather than a setTimeout, because a chained timeout gets throttled. A small queue (createSliceQueue.ts) gives the worker two lanes: ordinary requests run one at a time in order, and quick requests, a header or a window read, overtake at the running write's next slice.

Where It Is Refused

The first rule, that OPFS can be refused, we met in two places with identical wording.

Playwright's WebKit on Linux cannot open OPFS. navigator.storage.getDirectory() throws UnknownError: The operation failed for an unknown transient reason, though estimate() answers with a quota of 1.0 GB. We first read that as a Linux WebKit gap. Then an iPhone in Private Browsing threw the same error, on the main thread as much as in the worker, with estimate() answering zeros. In a regular tab on the same phone the cache worked, with five recordings kept and no warning. Our best explanation is that the wording is WebKit's ephemeral session refusing storage, not a device limit or a transient anything. A cache has to treat it as a miss, and ours does: the facade warns once and the song plays from the network as designed.

So Safari's real behavior is checked on a real device, by hand. Vitest cannot exercise OPFS at all; there is no navigator.storage.getDirectory in Node or jsdom. What we could do was import the page-side module into a Playwright Chromium page on the dev server (a dynamic import() of the source path works under Vite) and run write, stat, read, listing, prune, forget and clear against the real directory. The pure parts, the budget and the eviction order, have ordinary unit tests.

A Half-Written File Is No File

The second rule, that storage can vanish mid-write, shaped the write path. A write goes to <key>.part and is moved over the key's file only when whole, where the browser has FileSystemHandle.move. Where it does not, the write goes to the key's own file, but the key is struck from the saved index first, so a write cut short by a closed tab leaves a file the index does not list, and the next open deletes it. If anything throws during a write (the quota ran out, say), the worker removes the half-written file and saves the index without it. The media cache goes one step further on read: if the file's length does not match the length the index recorded, the copy is dropped and the read is a miss.

Reads of the compressed renditions take no lock at all. FileSystemFileHandle.getFile() returns a File any worker can slice, so the stream worker reads a kept song through a blob source while the cache worker may be writing a different file, and it trusts only a key the saved index lists at the file's exact size, so a .part in flight is never mistaken for a song.

The Recorder's Queue

The same instinct, in a different store, runs the Idea Recorder. The recorder is for capturing ideas fast, so Record has to be available again the moment Stop is pressed, and a take must not be lost to a refresh, a crash, or a phone switching apps during the upload.

A stopped take goes into a queue (src/lib/audio/takeQueue.svelte.ts) that writes it to IndexedDB first, in a pendingTakes store, and only then starts the upload. Uploads run one at a time in recording order, so the server's take numbers follow the order the takes were made. When the take lands, its row is deleted. On the next visit, restore() reads whatever rows are left and resumes the uploads at once, creating the idea the take belonged to if that never happened either. Measured in Chromium, Stop to Record-enabled is about 70 to 105 ms.

The queue is tested on fake-indexeddb, with a fresh IDBFactory per test, and the tests read the rows straight out of the fake store to prove what was persisted and when. That test paid for itself on the first run. The queue remembers how far a take with several source files got (the mix saved, N sources up) so a retry after a reload carries on from there, and it was writing that progress through a Svelte $state proxy. IndexedDB clones what it stores, a reactive proxy cannot be cloned, so the write failed quietly and every reload started the take over. The fix is one call:

/** `$state.snapshot` first: IndexedDB clones the row, and a reactive proxy cannot be cloned. */
const persist = (t: PendingTake) =>
	withStore("readwrite", (s) => s.put($state.snapshot(t))).catch(() => undefined);

The General Lesson

Browser storage is a cache with a budget you share and a kill switch you do not hold. OPFS is fast enough that disk speed stops being a design input, and generous enough that the quota rarely binds, but any call can be refused on a private tab, any file can be gone next week, and any write can be cut off by a closed tab. Design every copy as optional: a facade that never throws, an index that lists only whole files, a budget that evicts whole units, and a verification step after every write you intend to rely on. Then the fast path is fast, the slow path still works, and nothing the user made depends on the disk being there.

The files this cache keeps are the subject of a companion post on writing files other programs trust.

headshot of Kevin Peckham
Kevin Peckham
Principal, Lightning Jar

Ask Eljay

Ask about the studio's work, research, packages, or writing. A few starters:

Answers come from this site's own content and link their sources. For anything that matters, email hello@lightningjar.com.