I build MakeQuickWork, a small set of free browser tools, on my own. This post is about the Resize Image tool and the three things that took longer than the resize itself.
Resizing an image in the browser looks like one call: draw the image onto a smaller canvas with drawImage. That works, and on a big photo it also looks bad. The edges go jagged and fine texture turns into noise.
Getting it to look right took one loop. Most of the rest of the time went into the size fields: what "keep aspect ratio" should mean when someone enters both a width and a height, and how big a canvas a phone will actually agree to create.
The tool is here: https://makequickwork.com/image/resize-image/. Everything runs in the tab. The image is decoded, resized and encoded locally, and the result is a normal download.
Decoding: let the browser rotate it
Phone photos are often stored sideways with an EXIF Orientation tag. If you ignore it, a portrait photo comes out lying down. createImageBitmap can apply it for you:
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
HEIC is the exception. Safari decodes it natively; Chrome, Edge and Firefox don't. So the decoder tries createImageBitmap first and falls back to libheif compiled to WebAssembly, which is only downloaded the first time someone actually opens a HEIC file. The page itself loads about 11 KB of JavaScript.
Downscaling: halve, then finish
A single drawImage from 3200 px wide to 1080 px samples too few source pixels per output pixel, and the result aliases. Raising imageSmoothingQuality to "high" helps, but browsers differ in how much. Halving repeatedly until the next halving would undershoot, and doing the last step at the exact size, gives consistently clean results:
/** Resizes with repeated halving, which stays sharp where a single large downscale gets jagged. */
export function resize(src: HTMLCanvasElement, width: number, height: number) {
let cur = src;
while (cur.width / 2 >= width && cur.height / 2 >= height) {
const half = newCanvas(cur.width / 2, cur.height / 2);
const ctx = half.getContext("2d")!;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(cur, 0, 0, half.width, half.height);
if (cur !== src) cur.width = cur.height = 0; // free the intermediate
cur = half;
}
const out = newCanvas(width, height);
const ctx = out.getContext("2d")!;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(cur, 0, 0, width, height);
if (cur !== src) cur.width = cur.height = 0;
return out;
}
Setting width = height = 0 on canvases you're done with matters more than it looks. On iOS, canvas memory is tight and isn't always released promptly, so a batch of large photos can fail halfway if intermediates hang around.
Enlarging goes through the same function. The loop simply doesn't run, and you get a single high-quality drawImage. It's allowed, but it can't invent detail. More on that below.
"Keep aspect ratio" means a box
This was the design question I spent the most time on. If keep-ratio is on and someone types width 1080 and height 1080 for a 4:3 photo, there are three reasonable answers: crop to a square, stretch to a square, or fit inside the square. I picked fit. Width × height is a box, and the image is scaled by the smaller of the two factors:
const sx = o.width > 0 ? o.width / w : Infinity;
const sy = o.height > 0 ? o.height / h : Infinity;
const s = Math.min(sx, sy);
if (!Number.isFinite(s)) throw new Error("Enter a width or a height.");
An empty field becomes Infinity, so leaving the height blank means "width 1080, height whatever it needs to be". It also makes batches behave: a landscape and a portrait image in the same 1080 × 1080 box each keep their own proportions. There is no cropping anywhere in the tool. If you need an exact size, turn keep-ratio off and the image is stretched to it.
The width and height fields are prefilled from the first image's dimensions, and while keep-ratio is on, typing one side recalculates the other from that image's ratio. That way the numbers you see always match what the first image will become.
The canvas size ceiling
iPhone Safari refuses to create canvases above roughly 16 megapixels. It doesn't throw a helpful error; you just get a blank image. So every target size is clamped before anything is drawn:
const s = Math.min(1, MAX_SIDE / tw, MAX_SIDE / th, Math.sqrt(16_000_000 / (tw * th)));
With MAX_SIDE = 16_000, output stays within 16,000 px per side and about 16 MP total in every browser. If you ask for 8000 × 6000, you get less than that, scaled down proportionally. I'd rather be honest about the limit than have the tool work on a desktop and silently fail on a phone.
Encoding and the batch
JPG and WebP are saved at 92% quality; PNG stays lossless. WebP needs a detour: Safari's canvas.toBlob can't write WebP, so WebP output goes through libwebp compiled to WebAssembly in every browser. Multiple results are zipped with fflate. Output names carry the size, like photo-1080x720.jpg, so a folder of exports stays readable.
EXIF doesn't survive the round trip through a canvas, so GPS location and camera details are gone from the output. For most uses that's a bonus, but if you need the metadata, keep the original.
What it looks like on real files
On my test images, with width 1080, height empty and keep-ratio on:
-
beach-sunset.jpg: 3200 × 2400, 2.9 MB → 1080 × 810, 68.8 KB, saved asbeach-sunset-1080x810.jpg -
receipt.jpg: 900 × 1200, 41.3 KB → 1080 × 1440, 63.8 KB
The second one is the case I want to point out. The receipt was narrower than 1080, so the box enlarged it. The file got bigger and the text got slightly softer, because upscaling interpolates pixels; it doesn't recover anything. If you only want to shrink, check the source dimensions first or use the percentage mode.
How I check nothing is uploaded
The site is static (Astro on Cloudflare Pages), and there is an automated test that processes a file in every tool and fails on any POST, or any third-party request other than Google Analytics, during processing. It also fails if an analytics event contains a file name. You can verify it yourself in the DevTools Network tab. You do need a connection to load the page and the WebAssembly modules.
If you've solved the downscale problem a different way on canvas, or think crop-to-fill should be an option next to fit, I'd like to hear about it in the comments.




Top comments (1)
The halving loop is a nice fix, and clamping to the iOS limit up front instead of handing back a blank image is the kind of thing most tools skip.
One case where smoothing works against you: pixel art and game sprites. Halving with smoothing on blurs hard 1 px edges into grey mush, and upscaling a 32 px sprite to 128 px comes out soft. For those you want
imageSmoothingEnabled = falseand whole-number factors (2x, 3x, 4x), otherwise some pixels end up 2 px wide and some 3 px. A "pixel art / nearest neighbour" toggle would cover that crowd. A rough auto-detect is to count unique colours, since pixel art usually has under a few hundred.The same applies to sprite sheets: with smoothing, each frame picks up a thin line from its neighbour at the cell edges, because the filter samples across the boundary. Resizing each frame on its own canvas, or nearest neighbour at an integer factor, avoids it.