Canvas image compression to a target size
Downscale and re-encode an image in the browser, lowering JPEG or WebP quality until it fits a byte budget.
React / Next.jsUtilitiescanvasimagecompressiontypescript
Code
export type CompressOptions = {
maxSide?: number; // longest edge in px
maxBytes?: number; // optional size budget
type?: 'image/jpeg' | 'image/webp';
quality?: number; // starting quality 0..1
minQuality?: number;
};
const encode = (canvas: HTMLCanvasElement, type: string, quality: number) =>
new Promise<Blob>((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Encoding failed'))), type, quality),
);
export async function compressImage(file: Blob, opts: CompressOptions = {}): Promise<Blob> {
const { maxSide = 2000, maxBytes, type = 'image/webp', quality = 0.85, minQuality = 0.3 } = opts;
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas not supported');
ctx.fillStyle = '#fff'; // avoid black backgrounds when transparency meets JPEG
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
let best = await encode(canvas, type, quality);
if (!maxBytes || best.size <= maxBytes) return best;
// Binary search for the highest quality that still fits the budget.
let lo = minQuality;
let hi = quality;
for (let i = 0; i < 6; i++) {
const mid = (lo + hi) / 2;
const blob = await encode(canvas, type, mid);
if (blob.size <= maxBytes) {
best = blob;
lo = mid;
} else {
hi = mid;
if (i === 0) best = blob.size < best.size ? blob : best; // keep the smallest seen
}
}
return best;
}
How it works
What it solves
Uploading a 6 MB phone photo to produce a 200 KB avatar wastes bandwidth and server time. Doing the resize and re-encode in the browser means only the small result travels, and no image library is needed because the canvas can already encode JPEG and WebP.
How it works
createImageBitmap decodes the file off the main thread and applies EXIF orientation. The function computes a scale factor so neither side exceeds maxSide, draws the bitmap to a canvas of that size, then calls toBlob. If a maxBytes budget is given, it runs a binary search over quality between a floor and the starting value: each probe encodes once, and the search keeps the highest quality that still fits. Binary search needs about six encodes instead of dozens from a linear loop.
PNG ignores the quality argument, so the code switches lossy output to WebP unless the caller asks for JPEG. A white fill is painted first so transparent pixels do not turn black when converting to JPEG.
Adapting it
- Use
OffscreenCanvasandconvertToBlobinside a worker for large batches. - Return the compression ratio by dividing
blob.sizebyfile.sizein the caller. - Lower
minQualityfor aggressive thumbnails.
Pitfalls
- If even the minimum quality exceeds the budget, the smallest result is returned; check
blob.sizeafterwards. - Safari older than 17 cannot encode WebP; fall back to
image/jpegthere. - Always call
bitmap.close()to release memory.
How to use it
- Save as
lib/compressImage.ts; it only uses browser APIs. - Call it from a client component:
const out = await compressImage(file, { maxSide: 1600, maxBytes: 300_000 }). - Create a preview with
URL.createObjectURL(out)and revoke it when done. - Upload
outusingFormData.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.