Resizing Images Without Blur or Stretching, Inside the Browser

A form asks for a photo of exactly 600 × 600 pixels. Your phone photo is 4032 × 3024. Resize it carelessly and you get either a face that looks squashed sideways or a picture that looks soft and smeared. When I built ToolOnTap’s image resizer, most of the work went into small rules about sizes, plus one hard limit that browsers don’t tell you about.

Pixels or percentage

The resizer has two modes, “By pixels” and “By percentage”, and they answer different questions.

By pixels is for when someone else sets the size: a form, a shop theme, a blog column. You type a width, a height or both, each from 1 to 20,000.

By percentage is for when you just want the image smaller, or larger, by a known amount. There are buttons for 25%, 50% and 75%, and you can type anything from 1% to 1000%. 25% of 4032 × 3024 gives 1008 × 756.

Both modes round to whole pixels and never go below 1 pixel per side. An empty or out-of-range field doesn’t produce an error or a broken image. It’s ignored, and with no usable number at all the image keeps its original size. That matters because the tool updates while you type.

Aspect ratio: fit, derive or stretch

The aspect ratio is the shape of the image, its width divided by its height. A 4032 × 3024 photo is 4:3. Most “why does it look wrong” problems come from changing that shape by accident. Here’s how the resizer decides the output size:

export function resizeTarget(source: Size, o: ResizeOptions): Size {
  if (o.mode === 'percent') {
    const pct = valid(o.percent, 1000)
    return pct ? scaleBy(source, pct / 100) : { ...source }
  }
  const width = valid(o.width)
  const height = valid(o.height)
  if (width && height && o.keepAspect) return scaleBy(source, Math.min(width / source.width, height / source.height))
  return fitDimensions(source, { width, height })
}

That gives three cases for “By pixels”:

You enter “Keep aspect ratio” 4032 × 3024 becomes
Width 1600 only either 1600 × 1200
600 × 600 on 600 × 450
600 × 600 off 600 × 600, stretched

With only one side, the other is worked out from the original shape, whatever the checkbox says. With both sides and the box checked, the image is fitted inside the box. The Math.min picks the scale that makes the tighter side fit, so a 4:3 photo in a 600 × 600 box comes out 600 × 450. Nothing is cut off and nothing is distorted, but one side is smaller than you typed. The option shows “(fits inside width × height)” next to it so that isn’t a surprise.

With the box unchecked, you get the exact size you asked for and the image is stretched to fill it. For a 4:3 photo forced into a square, faces look about 25% narrower. If a form insists on an exact square, cropping to a square first and then resizing gives the right size without distortion.

The limit browsers don’t announce

A canvas has a maximum size, and going over it doesn’t always throw an error. The image may come out blank, or the tab may crash, depending on the browser. iOS Safari won’t draw a canvas bigger than 4096 × 4096 pixels, so that became the cap for every canvas-based image tool on the site:

// Largest canvas iOS Safari will draw (4096 × 4096). Bigger canvases silently fail or crash the tab.
export const MAX_CANVAS_PIXELS = 16_777_216

// Scale a size down (never up) so width × height stays within maxPixels, keeping the aspect ratio
export function clampPixels(size: Size, maxPixels = MAX_CANVAS_PIXELS): Size {
  const pixels = size.width * size.height
  if (pixels <= maxPixels) return { width: size.width, height: size.height }
  const scale = Math.sqrt(maxPixels / pixels)
  return {
    width: Math.max(1, Math.floor(size.width * scale)),
    height: Math.max(1, Math.floor(size.height * scale)),
  }
}

The limit is on the total number of pixels, not on each side, so a long panorama can be wider than 4096 as long as it’s short enough. The scale is a square root because it applies to both sides: to halve the pixel count, each side shrinks by about 0.71. Math.floor rounds down so the result never goes a pixel over.

In practice, if you ask for 300% of a 4032 × 3024 photo, that’s 12096 × 9072, about 110 megapixels. The resizer makes 4729 × 3547 instead, the largest 4:3 size under the cap, and the file says so: “4032 × 3024 → 4729 × 3547 px (scaled down to stay within your browser’s limits)”. Without that note, you’d assume the 300% worked.

The cap also keeps memory in check. A canvas holds 4 bytes per pixel, so a full 16.7-megapixel canvas is 64 MB of memory before encoding even starts.

Where blur comes from, and what the code does about it

There are three different reasons a resized image looks worse, and only some can be fixed.

Enlarging. Making an image bigger means inventing pixels between the ones that exist. A resampler blends neighbors smoothly, which looks soft. No setting can add detail that was never captured, so the tool allows it but the advice is to stay under about two times.

Shrinking a lot. Going from 4032 pixels to 400 means each new pixel stands for about a hundred old ones. A fast, simple resampler looks at only a few of them, and that gives jagged edges and shimmering patterns on fine texture such as fabric or brick. The resizer avoids the simplest route. It asks the browser to decode the file straight to the target size with its high-quality setting:

// Decode straight to the size we need, so huge photos never exist at full size in memory
export async function decode(file: Blob, size: Size): Promise<ImageBitmap> {
  try {
    return await createImageBitmap(file, { resizeWidth: size.width, resizeHeight: size.height, resizeQuality: 'high' })
  }
  catch {
    return createImageBitmap(file)  // older browsers without resize options
  }
}

It’s meant to save memory too. The browser can decode a 48-megapixel photo straight to 1600 pixels instead of holding it at full size first. The standard leaves the exact filter behind 'high' to each browser, so results can differ slightly between Chrome, Firefox and Safari. For older browsers without these options, the image is decoded at full size and drawn onto the canvas with imageSmoothingQuality set to 'high'.

Saving again. JPG and WebP results are encoded at quality 0.92, which keeps the blocky look of heavy compression away. PNG results are lossless. Under “Save as”, “Same as original” keeps JPG, PNG and WebP in their format, and saves anything else as JPG.

One more rule prevents a slow kind of damage. When you change a setting, the tool waits 400 ms for you to stop typing, then resizes every image again from the original file, never from the previous result. Trying 50%, then 75%, then 100% gives you the original size back at full sharpness, not a copy enlarged from a half-size version.

A sensible order

  • Crop, then resize, then compress. Cropping picks the shape, resizing sets the pixels, and compression works best on an image that’s already the right size.
  • For mixed photos, enter one side only. Portrait and landscape photos then keep their own shapes at the same width.
  • For the web, use the width the image is shown at. A 4000-pixel photo in an 800-pixel column is wasted bytes.

Try it

The image resizer works on one image or a whole batch, with the old and new dimensions shown next to each file and a ZIP download for the lot. It runs on your device. You’ll find cropping and compression on the image tools page.

Tools in this article

  • Resize images

    Resize JPG, PNG and WebP images to exact pixel dimensions or by percentage. Keep the aspect ratio, resize in bulk, no upload.

More articles