Validating Image Dimensions Client-Side

How do you reject an avatar that is 80 × 80 pixels when you need at least 400 × 400, or a banner that is square when you need 3:1 — before the upload, and with a message that states both the required and the actual size? This recipe decodes the image with createImageBitmap(), reads its real, rotation-corrected dimensions, checks minimums, maximums and aspect ratio with a tolerance, and reports the verdict through setCustomValidity() on the file input so it behaves like any other rule in the Constraint Validation API flow.

When to Check Image Dimensions in the Browser

Dimension rules exist whenever an image will be displayed at a fixed size or printed: avatars, product photos, banners, identity documents, print-on-demand artwork. Checking them in the browser pays off when:

  • The rule is strict, such as a marketplace requiring product photos of at least 1000 px on the long edge.
  • The files are large, so uploading first and rejecting later wastes minutes on mobile.
  • You can offer a fix — cropping to the right ratio in the browser — rather than only rejecting.

This check should run after the cheap size and type checks from the file upload validation pipeline, because decoding is the most expensive step. There is no reason to decode a 40 MB HEIC file you are about to reject for type.

Common image dimension rules Cards showing typical dimension rules for different kinds of uploaded images. Avatar minimum 400 × 400, square within 5% Product photo long edge at least 1000 px Banner 3:1 ratio, at least 1500 × 500 Document scan at least 1200 px wide for legibility Print artwork 300 dpi at print size, e.g. 3508 × 4961 for A4 Any image maximum 40 megapixels to protect memory
Each use has a different binding constraint — a minimum for quality, a ratio for layout, a maximum for memory.

Minimal Working Dimension Validator

export interface DimensionRules {
  minWidth?: number;
  minHeight?: number;
  maxPixels?: number;           // width × height
  aspect?: { ratio: number; tolerance: number; label: string }; // e.g. 3 ± 0.05, "3:1"
}

export interface Measured { width: number; height: number }

export async function measureImage(file: File): Promise<Measured> {
  // imageOrientation "from-image" applies EXIF rotation, so a portrait phone photo reports portrait sizes.
  const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
  try {
    return { width: bitmap.width, height: bitmap.height };
  } finally {
    bitmap.close(); // release decoded pixels immediately
  }
}

export function dimensionError(name: string, m: Measured, r: DimensionRules): string {
  const size = `${m.width} × ${m.height} px`;
  if (r.minWidth && m.width < r.minWidth || r.minHeight && m.height < r.minHeight) {
    return `${name} is ${size}. It needs to be at least ${r.minWidth ?? 0} × ${r.minHeight ?? 0} px.`;
  }
  if (r.maxPixels && m.width * m.height > r.maxPixels) {
    return `${name} is ${size}, which is larger than we can process. Resize it to under ${Math.round(r.maxPixels / 1e6)} megapixels.`;
  }
  if (r.aspect) {
    const ratio = m.width / m.height;
    if (Math.abs(ratio - r.aspect.ratio) / r.aspect.ratio > r.aspect.tolerance) {
      return `${name} is ${size}. It needs a ${r.aspect.label} shape — crop it or choose another image.`;
    }
  }
  return "";
}

// Wiring
const AVATAR: DimensionRules = { minWidth: 400, minHeight: 400, maxPixels: 40e6, aspect: { ratio: 1, tolerance: 0.05, label: "square" } };

const input = document.querySelector<HTMLInputElement>("#avatar")!;
const status = document.querySelector<HTMLElement>("#avatar-status")!; // role="status"
let checking: Promise<void> = Promise.resolve();

input.addEventListener("change", () => {
  const file = input.files?.[0];
  if (!file) return input.setCustomValidity("");
  input.setCustomValidity("Checking image…");       // block submit while decoding
  status.textContent = `Checking ${file.name}`;
  checking = measureImage(file)
    .then((m) => {
      const error = dimensionError(file.name, m, AVATAR);
      input.setCustomValidity(error);
      status.textContent = error || `${file.name} is ${m.width} × ${m.height} px — looks good.`;
    })
    .catch(() => {
      input.setCustomValidity(`We couldn't open ${file.name} as an image. Try a JPEG or PNG.`);
      status.textContent = "";
    });
});

input.form!.addEventListener("submit", async (event) => {
  event.preventDefault();
  await checking;
  if (input.form!.reportValidity()) input.form!.submit();
});

Setting a temporary “Checking image…” custom validity while decoding is a small but important trick: if the user submits during the decode, reportValidity() has a truthful message to show instead of letting an unchecked image through.

Measuring an image without blocking the page The file input change handler asks createImageBitmap to decode the image off the main thread, reads the rotation-corrected size, releases the bitmap and sets the input's custom validity. File input Validator Image decoder change: avatar.jpg setCustomValidity("Checking image…") createImageBitmap(file, from-image) 3024 × 4032 (EXIF rotated) bitmap.close() frees pixels setCustomValidity("") or error
Decoding happens off the main thread; the input stays invalid with a "checking" message until the verdict arrives.

Dimension Rule Option Reference

Option Type Default Purpose
minWidth / minHeight number (px) none Quality floor for display or print
maxPixels number 40e6 Protects memory when decoding and processing
aspect.ratio number none Width divided by height (1 for square, 3 for 3:1)
aspect.tolerance number 0.05 Relative tolerance so 1000 × 990 still counts as square
imageOrientation "from-image" on Applies EXIF rotation before measuring
Pending message string “Checking image…” Keeps the input invalid while decoding

A tolerance is essential for ratios. Phone cameras and cropping tools produce sizes like 1080 × 1079; an exact equality check rejects them for a one-pixel difference that nobody can see.

Example dimension verdicts for an avatar rule A table of uploaded image sizes and whether each passes a square avatar rule with a 400 pixel minimum and 5 percent tolerance. Minimum 400 Square ±5% Verdict 1080 × 1080 ✓ Yes ✓ Yes ✓ accepted 1080 × 1050 ✓ Yes ✓ 2.8% off ✓ accepted 1920 × 1080 ✓ Yes ✗ 78% off ✗ crop needed 320 × 320 ✗ No ✓ Yes ✗ too small
Tolerance lets near-square photos through; the minimum and the ratio each catch a different kind of unsuitable image.

Verification Steps

import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";

test("landscape photo fails the square avatar rule", async ({ page }) => {
  await page.goto("/profile");
  await page.getByLabel("Profile photo").setInputFiles({
    name: "wide.png",
    mimeType: "image/png",
    buffer: readFileSync("fixtures/1920x1080.png"),
  });
  await expect(page.getByRole("status")).toContainText("needs a square shape");
  expect(await page.getByLabel("Profile photo").evaluate((el: HTMLInputElement) => el.validity.customError)).toBe(true);
});

Edge Cases and Failure Modes

EXIF rotation. Phones often store a portrait photo as landscape pixels plus an orientation tag. Without imageOrientation: "from-image", you measure the raw pixels and reject a perfectly good portrait photo as landscape. The option is supported wherever createImageBitmap accepts options; for the Image fallback, CSS image-orientation: from-image is the default in modern engines, so naturalWidth is already rotated.

Decompression bombs. A small PNG can declare 50,000 × 50,000 pixels and exhaust memory when decoded. Read the header dimensions first for PNG (bytes 16–23) and reject anything above maxPixels before calling createImageBitmap.

async function pngHeaderSize(file: File): Promise<Measured | null> {
  const b = new DataView(await file.slice(0, 24).arrayBuffer());
  if (b.getUint32(0) !== 0x89504e47) return null;           // not a PNG
  return { width: b.getUint32(16), height: b.getUint32(20) };
}

Formats the browser cannot decode. HEIC decodes only in Safari; createImageBitmap rejects elsewhere. Catch the rejection and say which formats work, rather than calling the image “corrupt”.

Animated images. GIF and animated WebP report the first frame’s size, which is almost always the size you want. If you need to forbid animation, that is a separate check on the file’s frame count.

Offering an In-Browser Crop Instead of Rejecting

For ratio failures, the most helpful response is often to fix the image rather than reject it. A simple crop-to-centre gives a usable square avatar from any photo in a few lines, and a crop UI lets users pick the region. Either way, the crop produces a new File that goes back into the input through DataTransfer, and validation runs again on the result — it should pass by construction, but running the same check keeps one source of truth.

async function cropToSquare(file: File, size = 800): Promise<File> {
  const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
  const edge = Math.min(bmp.width, bmp.height);
  const canvas = new OffscreenCanvas(size, size);
  canvas.getContext("2d")!.drawImage(bmp, (bmp.width - edge) / 2, (bmp.height - edge) / 2, edge, edge, 0, 0, size, size);
  bmp.close();
  const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.9 });
  return new File([blob], file.name.replace(/\.\w+$/, "-square.jpg"), { type: "image/jpeg" });
}

async function replaceWithCrop(input: HTMLInputElement): Promise<void> {
  const cropped = await cropToSquare(input.files![0]);
  const dt = new DataTransfer();
  dt.items.add(cropped);
  input.files = dt.files;
  input.dispatchEvent(new Event("change", { bubbles: true }));
}

Always ask before cropping — “Crop to a square from the centre?” with a preview — because automatic cropping can cut off exactly the part of the photo the user cared about. And keep the minimum-size rule firm: upscaling a 200 px image to 400 px passes the check while producing the blurry avatar the rule was meant to prevent.

Server-Side Dimension Checks

Dimensions are cheap for the server to verify with an image library that reads headers without decoding, and it must, because a request can bypass the browser. The server should also enforce maxPixels before full decoding to protect itself from decompression bombs, and re-encode accepted images, which strips metadata such as GPS coordinates that users rarely mean to publish. Keep the rule values in the shared module the client imports, so “at least 400 × 400” means the same thing on both sides — the same discipline described in enforcing file size limits before upload.

Frequently Asked Questions

How do I get an image's width and height in the browser before uploading?

Call createImageBitmap(file, { imageOrientation: "from-image" }) and read width and height, then call close() to free memory. The older approach loads an object URL into an Image and reads naturalWidth and naturalHeight.

Why does my portrait photo report landscape dimensions?

Phones often store portrait photos as landscape pixels with an EXIF orientation tag. Pass imageOrientation: "from-image" so the reported size reflects the rotated image users actually see.

How strict should an aspect ratio check be?

Allow a small relative tolerance, around 2 to 5 percent. Exact equality rejects images that are one pixel off, which no one can see.

Should I upscale images that are too small?

No. Upscaling passes the size check but produces the blurry result the minimum was meant to prevent. Ask for a larger image instead.

← Back to File Upload Validation