A modern phone photo is 4000 pixels wide and several megabytes. Your avatar slot is 128 pixels. Your product gallery shows images at 1600 pixels at most.
Most apps upload the full photo anyway, then resize it on the server. On a slow mobile connection, the user waits for megabytes they will never see, and you pay for the bandwidth and storage.
The browser can do the resizing itself before the upload starts. That's what compressly does.
The basic case
import { compressImage } from "compressly";
async function upload(file: File) {
const small = await compressImage(file, {
maxWidth: 1600,
quality: 0.8,
targetSizeMB: 0.5,
mimeType: "image/webp",
});
const body = new FormData();
body.append("photo", small);
await fetch("/api/upload", { method: "POST", body });
}
compressImage returns a regular File, so the rest of your upload code doesn't change.
The details that usually go wrong
Sideways photos. Phones often store the camera orientation in EXIF data instead of rotating the pixels. Draw that image on a canvas naively and it comes out sideways. compressly reads the orientation and rotates the image first (autoRotate, on by default).
A frozen page. Decoding and re-encoding a large image takes time. compressly uses a Web Worker with OffscreenCanvas where the browser supports it, so the page keeps scrolling and the progress bar keeps moving.
Hitting a size limit. If your storage or API has a hard limit, pass targetSizeMB and compressly makes up to four passes, lowering the quality and then the dimensions, to get under it.
Small files. Files already under maxSizeMB (1 MB by default) and within the max dimensions are returned as they are, so you don't lose quality for nothing. Pass forceResize: true if you always want the max dimensions applied.
Showing the user what happened
import { compressImageWithDetails } from "compressly";
const result = await compressImageWithDetails(file, { maxWidth: 1600 });
console.log(result.originalSize, result.compressedSize, result.reductionPercentage);
You also get timeTakenMs and the final dimensions, which makes a nice "saved 87%" label.
Many files at once
import { compressImages } from "compressly";
const files = await compressImages(Array.from(input.files), {
concurrency: 3,
maxWidth: 1600,
onBatchProgress: (percent, done, total) => console.log(`${done}/${total}, ${percent}%`),
});
Keep concurrency around 2 to 4 on phones, where memory is the limit.
React
"use client";
import { useImageCompressor } from "compressly/react";
export function AvatarInput({ onReady }: { onReady: (file: File) => void }) {
const { compress, isCompressing, progress } = useImageCompressor({ maxWidth: 512, quality: 0.85 });
return (
<>
<input
type="file"
accept="image/*"
disabled={isCompressing}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) onReady((await compress(file)).file);
}}
/>
{isCompressing && <progress value={progress} max={100} />}
</>
);
}
Version 0.1.1 ships the React entry with a "use client" directive, so it works in Next.js App Router files without extra setup.
One thing it doesn't do
Client-side compression saves bandwidth and makes uploads faster. It isn't a security check, because anyone can skip your JavaScript and upload directly. Keep validating file type and size on the server.
Try it
npm install compressly
It has zero dependencies and is about 2 kB gzip. Try it with your own photos on the demo site, and open an issue on GitHub if an image comes out wrong.
Top comments (0)