I built a batch image resizer that never uploads a photo. Here is the part I did not expect to be interesting: the resize itself is ten lines of canvas code, and almost every bad result comes from the geometry around those ten lines, not from the drawing call.
Disclosure up front: I built this tool — the free image resizer at https://toolkitloop.com/image-resizer. It resizes up to 10 images in the browser, in exact pixels or by percentage, with Fit, Crop or Pad modes, and downloads the batch as a ZIP. This post is about the decisions inside it, because they are the same decisions you will hit in any image pipeline you build.
The annoying version of this problem
The usual flow for resizing an image is: upload to a server, wait, download the result, repeat for the next nine images. For a single thumbnail that is tolerable. For a batch — ten product photos, a folder of screenshots — it is a slow, leaky loop, and every upload is a copy of your file living somewhere you do not control.
The browser already has everything needed to do this locally: createImageBitmap decodes the file, <canvas> draws it at a new size, and toBlob hands back the result. No server, no queue, no upload. The interesting work is deciding what rectangle to draw.
Fit, crop, pad: three rectangles, three promises
Given a source image of sw × sh and a target of tw × th, the scale factor for Fit is the smaller of the two ratios; for Crop it is the larger. That one-line difference is the whole feature:
function fitRect(sw, sh, tw, th, mode) {
const scale = mode === 'crop'
? Math.max(tw / sw, th / sh)
: Math.min(tw / sw, th / sh);
const dw = sw * scale;
const dh = sh * scale;
// Fit: center the smaller rect, leaving empty space.
// Crop: center the larger rect, letting edges overflow.
// Pad: same as Fit, then fill the canvas background first.
return { dx: (tw - dw) / 2, dy: (th - dh) / 2, dw, dh };
}
Fit promises "nothing is cut off" and pays for it with empty space. Crop promises "the box is exactly full" and pays for it with trimmed edges. Pad is Fit plus a background fill, so the box is full and nothing is cut. If you have ever wondered why two resizers disagree on the same input, it is almost always because they silently chose different promises. Making the choice explicit — and previewing it — removes a whole class of bug reports.
The draw itself is unremarkable, and that is the point:
async function resizeImage(file, tw, th, mode) {
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(tw, th);
const ctx = canvas.getContext('2d');
const { dx, dy, dw, dh } = fitRect(bitmap.width, bitmap.height, tw, th, mode);
ctx.drawImage(bitmap, dx, dy, dw, dh);
const blob = await canvas.convertToBlob({ type: 'image/png' });
bitmap.close();
return blob;
}
Two details worth keeping. First, bitmap.close() matters in a batch loop: ten decoded images held open at once will eat memory you could have freed. Second, the output format is a decision, not a default. Re-encoding a JPEG as PNG can balloon the file; re-encoding a PNG with transparency as JPEG destroys the transparency. Keep the output format close to the input unless you have a reason to change it.
The batch problem is not the loop
Resizing ten images is a loop over the function above. The batch problem is everything around the loop: progress reporting per file, one failure that should not kill the other nine, and a single download at the end instead of ten save dialogs. A ZIP download at the end is what makes batch feel like batch; without it you have just done the same single-image job ten times.
There is also a quiet privacy consequence worth naming. When resizing happens locally, the tool never sees the image at all — no filenames, no contents, no metadata sent to analytics. Resized exports are created without original metadata where the browser pipeline supports it. That is not a marketing line; it is what falls out of never uploading in the first place.
Upscaling: the honest limitation
Every resizer has one limitation it should admit: enlarging cannot create detail. A 200-pixel icon stretched to 1200 pixels will look soft in every implementation, because the information was never there. The honest behaviour is to let the user do it, show the preview, and not pretend an algorithm recovered something. Downscaling is forgiving; upscaling is where previews earn their keep.
Three lessons I would keep for the next image tool
- Name the geometry promise. Fit, Crop and Pad are not cosmetic options; they are different contracts with the user. Silent defaults here are bugs.
- Preview before download, especially for crops. A crop that cuts a face is obvious at preview size and expensive after re-upload.
- Batch is a UX feature, not a loop. Per-file progress, isolated failures, and one ZIP download are what make ten images feel different from one image ten times.
If you have built an image pipeline, which failure mode bit you first — the geometry, the format re-encode, or the batch download? I keep finding new edge cases in the crop preview, so I am curious where yours live.
Try the tool this came from: https://toolkitloop.com/image-resizer
Top comments (0)