Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

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,

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, 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. 🐰

how hard could it be

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.)

tab crash

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. 🎉

finally relief

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.

zombie files

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:

  1. List all files currently in OPFS.
  2. Try to grab each file's lock with { ifAvailable: true }.
  3. Got the lock? Nobody's using that file — delete it.
  4. 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.

automatic cleanup

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.

pulling the file out

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.

waiting it out

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. 🙃

ship it

Try it (and hopefully don't crash your tab):

DVD Screensaver Maker

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.