
I wanted a written memory budget for a page that re-encodes every photo in a folder before anyone on the team picks a save strategy. Most lines of it were easy. One only shows up in the last two seconds of the run, and one number I almost copied into it turned out to be the browser starting up. The numbers below come from desktop Chromium 149 on a 16 GB Mac and 300 synthetic 3024×4032 JPEGs in three nested folders. Every photo goes through createImageBitmap, a same-size OffscreenCanvas and convertToBlob as JPEG at 0.8. Memory means all browser processes added together, and MiB means 1024×1024 bytes.
The pixel line is the easy one
A decoded 3024×4032 photo is width × height × 4 bytes, 46.5 MiB. Decoding the whole folder first made that line grow with the folder (5,192 MiB at 100 photos; the 300-photo run was at 14,382 MiB 3.3 seconds in and I stopped it). A loop that releases the bitmap and zeroes the canvas before the next file keeps it at one photo's worth. That much is arithmetic. The two lines below took reading the samples.
The peak column was measuring startup
My summary table gave peaks of 668 MiB for the one-at-a-time loop, 653 MiB for writing each result as it finished, and 1,375 MiB for keeping all 300 results and zipping them at the end. Then I read the raw samples, taken about every 1.2 seconds rather than the 1 second I'd assumed. The 668 and the 653 were both the very first sample, 0.05 seconds in, before the loop had touched a photo. I haven't worked out what that startup memory is. It shows up in runs that do different work afterwards, so I don't charge it to the loop. During the loop, the version that throws results away sat between 111 and 192 MiB.
The high points at t≈0 are that startup sample, so read from the second sample on. Blue, the discard version, stays low and flat. Green, write each file now, climbs during the loop. Orange, keep everything for a ZIP, follows green almost exactly until the last two samples, then jumps. Purple is the same loop inside a Worker; its spike at about 1.2 s is a single sample I haven't traced. The test images are my own synthetic files.
Held results add about 1.43 MiB per photo
The 300 re-encoded JPEGs total 448,872,129 bytes, an average of 1.43 MiB each, and a ZIP built at the end needs every one of them alive until the end. To make that line visible I gave the loop a save step that reports what it still holds (mib formats bytes, kept is a plain array):
async function processWithLedger(fileList, sink) {
let held = 0;
for (const photo of fileList) {
const frame = await createImageBitmap(photo);
const rgbaBytes = frame.width * frame.height * 4; // costs this whatever the file size
const scratch = new OffscreenCanvas(frame.width, frame.height);
scratch.getContext('2d').drawImage(frame, 0, 0);
frame.close();
const out = await scratch.convertToBlob({ type: 'image/jpeg', quality: 0.8 });
scratch.width = scratch.height = 0;
held += await sink(photo, out); // bytes the sink still holds
console.log(photo.name, 'decoded', mib(rgbaBytes), 'held', mib(held));
}
}
const keepForZip = async (photo, blob) => { kept.push(blob); return blob.size; };
Six of the test photos through keepForZip, first and last lines:
IMG_0001.jpg decoded 46.5 MiB held 1.5 MiB
IMG_0006.jpg decoded 46.5 MiB held 8.5 MiB
A second sink that writes each JPEG and returns 0 printed held 0.0 MiB on all six lines. The chart doesn't agree. In the 300-photo run that green line climbed from about 175 to 563 MiB, while the JS heap stayed near 2 MiB. The folder picker couldn't open in my test setup, so those writes went to the browser's origin private file system (OPFS) through createWritable(), the same call a real tool makes on a folder from showDirectoryPicker(). My guess is that the browser buffers the writes outside the heap. I haven't found where.
The ZIP step costs one to two archives at the end
In its last 2.4 seconds the ZIP run went 545 → 845 → 1,375 MiB, so 830 MiB on top of the loop, against a 428.1 MiB archive. The ZIP writer is my own store-only one, no compression. My guess is that the kept parts and the finished archive were alive together. The 100-photo run doesn't fit cleanly, though: its final jump was 163 MiB against a 142.2 MiB archive. At 1.2-second sampling both peaks could be higher, so I budget the ZIP step at one to two times the output size, growing with photo count rather than concurrency.
The budget I write down now
Startup, plus the photo in flight, plus, on a ZIP path, every output held through the loop and one to two archives' worth at the end. Writing as you go isn't free either: the OPFS run crept up to 563 MiB, it just never jumped. A Worker doesn't change the arithmetic. It dropped the main thread's worst timer delay from 8.5 to 2.6 ms, and apart from that one untraced 856 MiB sample about 1.2 s in, it ran at 160–229 MiB, the same range as the main-thread loop. According to MDN's compatibility data (2026-10-15), Firefox and Safari have no showDirectoryPicker, so on those browsers a page has to hand results back as downloads, one by one or as an archive.
As an outside reference I use ImgIng's batch compressor (https://imging.ai/). Watching it on 100 of these photos in Chromium 149, it saved each one into a new folder before starting the next, and in the WebKit and Firefox engine builds I had, not the release browsers, its save option offered only one-by-one downloads. Before shipping a ZIP fallback, run it on the largest folder you expect and read the samples from the final seconds, not the peak column.
Top comments (0)