A teammate is building a small internal page where our ops people pick a folder of product photos and get JPEGs back for an upload form, all of it inside the browser. Before reviewing his loop I wanted every line of it backed by output I had run myself, with a stopwatch between the steps. The test photos are my own synthetic 3024×4032 JPEGs, the browser is desktop Chromium 149 on a 16 GB Mac, and MB means 1024×1024 bytes.
Hold one photo at a time
A decoded 3024×4032 photo is about 46.5 MB of RGBA, so the loop's whole memory strategy is to never hold two. In our earlier 300-photo runs, total browser memory for the one-at-a-time version with results thrown away stayed between 111 and 192 MB and the run took 24.6 seconds. Decoding everything first had reached 5,192 MB by photo 100.
Close the bitmap as soon as it is drawn
My rewrite, with a timer between steps. persist is whatever saves the result.
async function convertQueue(queue, persist) {
for (const item of queue) {
const label = item.webkitRelativePath || item.name, t = [performance.now()];
const lap = () => t.push(performance.now());
let photoBmp = null, paper = null;
try {
photoBmp = await createImageBitmap(item); lap();
paper = new OffscreenCanvas(photoBmp.width, photoBmp.height);
paper.getContext('2d').drawImage(photoBmp, 0, 0); lap();
photoBmp.close();
const jpeg = await paper.convertToBlob({ type: 'image/jpeg', quality: 0.8 }); lap();
await persist(label, jpeg); lap();
console.log(label, t.slice(1).map((x, i) => Math.round(x - t[i])).join('/'), 'ms', jpeg.size, 'B');
} catch (e) {
console.log(label, 'skipped:', e.name, e.message);
} finally {
photoBmp?.close();
if (paper) paper.width = paper.height = 0;
}
}
}
Once drawImage returns, the canvas holds its own copy of the pixels, so the bitmap has no reason to live through the encode. The awaited persist keeps the next decode from starting while the last JPEG is still being written. I haven't measured a version without that await, so this part is reasoning only. The finally covers files that fail halfway, and a second close() on an already closed bitmap threw nothing in my runs.
Two bad files were skipped and the rest finished
The test folder had six valid photos in three nested folders plus two traps: notes.jpg, a 65-byte text file, and IMG_0007.jpg, the first 64 KB of a JPEG. For the test, persist wrote each result into the browser's private file system (OPFS) through createWritable(), the same call a page uses on a folder the user picked. The four numbers are decode/draw/encode/save in milliseconds. Here are four of the eight lines from the first run:
photos/album-a/IMG_0001.jpg 28/3/50/2 ms 1530557 B
photos/album-a/notes.jpg skipped: InvalidStateError The source image could not be decoded.
photos/album-b/IMG_0007.jpg skipped: InvalidStateError The source image could not be decoded.
photos/album-b/IMG_0005.jpg 29/3/51/2 ms 1623854 B
The other four valid photos printed the same shape, with encode between 46 and 49 ms.
Both bad files reported image/jpeg as their File.type, because the browser takes the type from the extension, and only the decode step caught them. Without the try, that rejection would have ended the function at notes.jpg, the third file, and the ops person would have had two JPEGs out of six. The order isn't alphabetical either (IMG_0007 before IMG_0005), because that's how input.files handed them over, so the progress list should show each label instead of assuming a sorted queue.
Decode and encode take most of each photo
Across two runs, decode took 24–29 ms per photo and encode 46–52 ms, while draw took 3–4 ms and the OPFS write 2 ms, so about 75 of every 80 ms went to decode and encode. I don't trust the draw number much. My guess is that Chromium defers the real drawing work to convertToBlob, but I haven't verified it. In the earlier 300-photo runs, moving the loop into a Worker barely changed the total (23.5 s against 24.6 s) but cut the main thread's worst timer delay from 8.5 ms to 2.6 ms. Memory during the Worker loop sat at 160–229 MB, the same range as the main-thread version. For a page people keep clicking around in while it works, that's the version I'd ship.
Zeroing the canvas early did not break the JPEG
I also tried zeroing too early on purpose: call convertToBlob, set paper.width = 0 on the next line, then await. The JPEG was still 1,530,557 bytes and decoded back to 3024×4032, identical to the normal order. That suggests Chromium copies the canvas when the call starts. I only checked Chromium 149, so in our code the zeroing stays after the await.
Where persist writes to
In our page persist will write into a folder from showDirectoryPicker({ mode: 'readwrite' }). According to MDN's compatibility data (2026-10-15) Firefox and Safari don't have that call, so on those browsers persist has to become a download instead, and the loop itself doesn't change. For one-off folders outside our tool I use ImgIng (https://imging.ai/) on the same Mac. Its batch panel says each photo is saved and released before the next one starts, which is the same order as this loop.
Before this goes into review I'm checking four things: every file sits inside its own try/finally, the save is awaited before the next decode, memory is read from the task manager rather than the heap panel, and the test folder includes at least one file that isn't really an image. How the loop behaves in Firefox and Safari I still don't know.
Top comments (0)