My Web Worker Wrote Video Straight to Disk. Then It Started Leaving Bodies. 🧟
I'm learning AI engineering by pairing with Claude on real projects, and my current one is a hobby app: 📀 DVD Screensaver Maker. It's a browser-based bouncing DVD logo simulator. You can fully customize — colors, speed,
I'm learning AI engineering by pairing with Claude on real projects, and my current one is a hobby app: 📀 DVD Screensaver Maker.
It's a browser-based bouncing DVD logo simulator. You can fully customize — colors, speed, shapes, and more — then export as HTML, GIF, or MP4/WebM. No signup, no watermark, completely free.
For MP4/WebM specifically, every last frame renders and encodes right there on your machine — not a nice-to-have bolted on later, but the whole point. 🧐
That's the actual export, straight off the site above — untouched, unedited.
Why was "everything client-side" non-negotiable?
Two reasons, neither of them "because it's cool":
- The pitch is "nothing leaves your browser." A server-side encode step breaks that promise on day one.
- This is a hobby project. I'm not paying for GPU encoding infrastructure so strangers can watch a logo bounce — my hosting bill stays basically $0.
So everything had to happen in the browser, including the heavy part — encoding the video — and that decision sent me straight down the rabbit hole. 🐰
The problem: one long export away from crashing the tab
The obvious design goes like this.
Encode frames (VideoEncoder) → Mux (webm-muxer / mp4-muxer) → Hold entire result in memory → Hand user one big Blob
Both muxer libraries default to exactly this — an in-memory ArrayBufferTarget — and it works great for a 5-second clip.
But someone will eventually export 1080p for a full minute, and now the tab is holding a multi-gigabyte ArrayBuffer hostage.
Best case, your fan spins up like it's taking off 🚁. Worst case, the tab just dies 💀.
No error, no toast, just silence — and a user left wondering if they broke something.
(They didn't. I did.)
The fix: stream straight to disk with OPFS
Turns out both muxer libraries accept a FileSystemWritableFileStreamTarget.
Instead of building the video in RAM, give them a file and write each encoded chunk straight to disk as it's produced.
The disk in question is the Origin Private File System (OPFS).
- It's a sandboxed filesystem the browser gives your site.
- It's invisible to the user.
- Only your own JS can touch it.
- Crucially: it's still 100% client-side. No server involved.
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
new Mp4Muxer({
target: new Mp4FileTarget(writable),
video: { codec: 'avc', width, height, frameRate },
fastStart: false, // moov atom at the END — no need to know file size up front
});
The result: memory usage stays roughly constant no matter how long the export runs — a 1-hour 1080p export puts about the same RAM pressure on the tab as a 5-second one. 🎉
Plot twist: now I had zombie files
Streaming to disk solved the memory problem. It also created a new one, because it turns out OPFS is forever.
- Tab closes mid-export? File stays.
- Browser crashes? File stays.
- User wanders off and abandons the modal? File stays.
Either way: the file just... sits there. Forever. 🧟
Every abandoned export becomes a half-finished MP4 ghost, haunting the user's disk quota, never blinking, never leaving, never explaining itself. A tiny immortal freeloader I created and then forgot about.
So now I needed two things:
- A way to know: is this file still being actively written by a live tab?
- A way to clean up files left behind by tabs that are no longer around to clean up after themselves.
Enter the Web Locks API
Here's the trick. Every export grabs a named lock: opfs:<filename>.
It holds that lock for the file's entire lifetime:
- encoding
- ready-to-download
- a final size-scaled grace period after the download starts, before I finally delete the file (more on that below)
On page load, every tab runs a little sweep:
- List all files currently in OPFS.
- Try to grab each file's lock with
{ ifAvailable: true }. - Got the lock? Nobody's using that file — delete it.
- Couldn't get it? A live tab is holding it — leave it alone.
When a tab dies its lock releases automatically, so every other tab's startup sweep self-heals the system with no cleanup code, dialog, or cron job required.
Okay but how does a sandboxed file become a download?
Here's a detail I glossed over: OPFS is invisible. Your devtools barely show it. The user's OS definitely can't see it.
So even after the muxer finishes writing a perfectly good MP4 to disk, that file is still trapped inside the browser's private little vault.
Getting it out is almost anticlimactic:
const file = await fileHandle.getFile(); // OPFS handle -> real File object
const url = URL.createObjectURL(file); // give it a URL the page can use
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click(); // let the browser do its normal download thing
document.body.removeChild(a);
No showSaveFilePicker. No service worker trickery. No streaming the bytes anywhere.
Just: ask OPFS for the finished File, wrap it in an object URL, fake-click an <a download>, and let the browser's completely ordinary download machinery take it from there.
The part where I almost deleted the file mid-download
Here's the trap. Clicking the <a> doesn't mean the download is done. It means the browser has started copying that object-URL blob out to disk.
For a 5-second clip, that's instant. For a 20-minute 1080p export, that copy can take a few seconds — sometimes longer on a slow disk.
If I revoke the object URL and delete the OPFS file the moment .click() returns, I've pulled the file out from under a download that's still in progress.
Best case, nothing happens. Worst case, the user gets a corrupted, truncated video and no idea why.
So the cleanup has to wait. I keep the file's lock held, then delay the delete by an amount that scales with file size:
const safeDelay = Math.max(
20_000,
Math.ceil(file.size / (50 * 1024 * 1024)) * 1000 + 15_000
);
setTimeout(async () => {
await root.removeEntry(filename);
URL.revokeObjectURL(url);
releaseLock();
}, safeDelay);
20 seconds minimum, plus roughly another second per 50MB, plus a 15-second cushion on top.
It's not a precise measurement of "when is the download actually finished" — there's no browser API that tells you that. It's just "give it enough rope." ➰
Only when that timer fires do I revoke the URL, delete the file, and release the lock — the same lock the startup sweep checks, as a safety net if this tab never gets that far.
Was it worth it?
Yes. But it wasn't free.
The good:
- ✅ Constant memory usage, regardless of export length or resolution.
- ✅ No more crash-prone giant in-memory buffers.
- ✅ Still 100% client-side — no server, no upload, no bill.
The cost:
- ❌ Went from "encode → hand back a Blob" to file handles, lock coordination, and a startup sweep — a lot of moving parts for what sounds like a one-line change.
- ❌ OPFS support isn't universal, so you need a feature check (
navigator.storage.getDirectory) and a fallback plan. - ❌ Debugging got weirder — no more
console.log-ing a Blob, just files in a sandboxed filesystem your devtools barely show you.
For a toy project I built for fun, this is probably more engineering than the situation strictly demanded.
But "the tab silently dies on a big export" isn't a bug you get to ship. Even in a for-fun side project. 🙃
Try it (and hopefully don't crash your tab):
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.







