Converting one WebP to PNG in the browser is a solved problem: decode the image, draw it to a canvas, and call toBlob. Converting a batch is where the interesting problems live — memory growth, download orchestration, format branching, and the quiet ways a naive loop degrades quality. This post walks through a batch pipeline that stays correct, and the traps I found while building one. Disclosure: I built the free converter at https://toolkitloop.com/webp-to-png, which runs this pipeline entirely client-side; no image is ever uploaded.
The single-file pipeline, briefly
The core of any browser converter is three steps. First, decode the WebP into something drawable — createImageBitmap is the modern choice, since it decodes off the main thread and hands back a bitmap you can draw directly. Second, draw that bitmap onto a canvas at the image's intrinsic size. Third, export with canvas.toBlob, asking for image/png or image/jpeg plus a quality value for JPG.
async function convertOne(file, format, { quality, background } = {}) {
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
if (format === 'image/jpeg' && background) {
ctx.fillStyle = background;
ctx.fillRect(0, 0, bitmap.width, bitmap.height);
}
ctx.drawImage(bitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: format, quality });
bitmap.close();
return blob;
}
Note the ordering: the background fill happens before drawImage, and only for JPG. JPG has no alpha channel, so transparent pixels must be composited over a chosen colour before encoding — if you skip the fill, the encoder picks a default and you get whatever it picks, which is rarely what you wanted. PNG keeps the alpha channel, so no fill is needed and none should be applied.
Why batches break the naive loop
A tempting implementation maps over the file list, converts everything with Promise.all, and then triggers all downloads. It works for three small images and falls apart for thirty large ones, for three distinct reasons.
First, memory. Each decoded bitmap, each canvas, and each output blob is alive simultaneously under Promise.all. A batch of high-resolution screenshots can easily hold hundreds of megabytes of decoded pixels at once, and on a constrained device the tab pays for it. A sequential loop — convert one, download it, release it, then move on — keeps the peak at roughly one image, not the whole batch.
Second, downloads. Browsers throttle or block bursts of programmatic downloads. Firing ten anchor clicks in the same tick looks like abuse, and some browsers will silently drop all but the first few. Spacing downloads, or better, letting the user trigger each one from a completed list, is more reliable than a fire-and-forget burst.
Third, error isolation. In a Promise.all batch, one corrupt file rejects the whole batch, and the user loses the successful conversions alongside the failed one. A sequential loop with per-file try/catch converts what it can, reports the one failure by filename, and finishes the rest. For a user converting a folder, partial success with a clear error is vastly better than total failure with a stack trace.
for (const file of files) {
try {
const blob = await convertOne(file, format, options);
downloads.push({ name: rename(file.name, format), blob });
URL.revokeObjectURL(pendingUrl); // release the previous blob URL
} catch (err) {
failures.push({ name: file.name, reason: err.message });
}
}
The blob URL lifecycle is the leak nobody notices
Every downloadable result needs an object URL, and every object URL holds its blob alive until revoked. A batch converter that creates a URL per file and never revokes them leaks memory for the lifetime of the page — and if the user converts several batches in one session, the leak compounds.
The discipline is simple: revoke a URL as soon as its download has started, or when its list entry is removed. Do not revoke it synchronously inside the same click handler before the browser has had a chance to start the download; a short deferral, or revoking the previous file's URL when the next one is created, is the reliable pattern. And close the source bitmap with bitmap.close() once it has been drawn — decoded bitmaps are not garbage-collected as promptly as developers assume.
Format branching belongs before the loop, not inside it
A subtle correctness issue: the output format and its options should be decided once, before the loop starts, not re-derived per file. If the user picks JPG with a white background, every file in that pass gets JPG with a white background. Mixing formats inside one pass — PNG for this file, JPG for that one, based on sniffing transparency — sounds clever and produces folders nobody can reason about afterwards.
The better UX, and the one the converter at https://toolkitloop.com/webp-to-png follows, is two explicit passes: the user converts the transparency group as PNG, then the photograph group as JPG. The code stays simple, the output folder stays predictable, and the transparency decision — the one decision that actually matters — is made by a human looking at the images, not by a heuristic guessing from pixel data.
There is one more reason to keep passes separate: quality. JPG's quality parameter only applies to JPG output and is ignored for PNG. A loop that passes the same options object to both formats will silently ignore quality for PNG files, which is harmless, but it will also tempt a future maintainer to "fix" it by applying quality-like logic to PNG — for example, by downscaling. Downscaling is not compression, and conflating the two is how converters quietly destroy images.
What not to promise
A browser converter should be honest about three limits. It cannot recover detail that lossy WebP encoding already discarded — decoding preserves the decoded pixels, nothing more. It cannot make a JPG transparent, no matter what the UI implies; the background fill is a real, visible choice. And it should not claim to handle "unlimited" batches when the implementation holds decoded images in memory: a small batch, processed sequentially, is the honest scope, and it matches how people actually use the tool — a handful of files for one article, one upload form, one handoff.
Batch conversion in the browser is therefore less about codecs and more about resource discipline: decode one file, draw it, export it, release everything, and only then move on. Get that loop right, and the converter is boring in the best way — it converts every file you give it, reports the one it could not, and leaves the tab no heavier than it found it.
What does your batch pipeline do about failed files — abort everything, or finish the rest and report? I have seen both in production tools, and the second is rarer than it should be.
About the author
- Founder of ToolkitLoop (toolkitloop.com) — 30 free online tools that run 100% in your browser. No sign-ups, no uploads, private by design.
Comments
Join the discussionNo comments yet. Be the first to comment.
