Browser-Side Image Compression That Skips the Server Entirely
How to Compress Images Purely on the Frontend? No Server Uploads, All Logic Runs in the Browser
Recently, I added an image compression feature to my tool site. The entire process is completed in the browser, and images are never uploaded to any server. I encountered quite a few pitfalls during implementation, and this article organizes the key ideas and code. Tool address: tools.memory.gd.cn/image/compress
The Conclusion First: The Browser Can Do It, but Know the Boundaries
Many people think image compression must be done server-side, but that's not necessarily true. Browser-side compression has clear advantages:
- Privacy and Security: Images never leave the device, eliminating any risk of leakage.
- Zero Server Costs: No need to buy bandwidth or store files.
- Instant Feedback: Local processing means no waiting for uploads and downloads.
But we must also acknowledge the limitations: the compression effect of Canvas redrawing is not as extreme as TinyPNG's lossy algorithm. If your requirement is "ultimate compression ratio," you still need the server side. However, in most scenarios, a 40-60% compression ratio on the browser side is sufficient.
Core Dependency: browser-image-compression
Don't reinvent the wheel; use the browser-image-compression library directly. It has over 500,000 weekly downloads and is commonly used in the React community.
npm install browser-image-compression
Basic usage is very simple:
import imageCompression from "browser-image-compression";
const compressed = await imageCompression(file, {
maxSizeMB: 10,
useWebWorker: true,
initialQuality: 0.8,
});
Key parameters:
initialQuality: 0-1, controls compression quality.useWebWorker: Whether to use a Web Worker to avoid blocking the main thread (important!).maxWidthOrHeight: Limits the longest side; if exceeded, the image is scaled down proportionally.fileType: Output format, supports JPG / WebP / PNG.
Pitfall 1: Re-fetching the Data URL on Every Re-compression
In the initial implementation, the data URL was converted back to a Blob on every compression:
// ❌ Fetches every time, high overhead
const blob = await fetch(original.src).then((r) => r.blob());
const file = new File([blob], name, { type: blob.type });
const result = await compress(file, { ... });
For large images, this step causes noticeable lag. The solution is to cache the original File object upon upload:
const originalBlobRef = useRef<File | null>(null);
async function handleFile(file: File) {
originalBlobRef.current = file; // Cache directly
// ... render preview
}
const doCompress = useCallback(async (name, q, fmt, maxW) => {
if (!originalBlobRef.current) return;
const file = new File([originalBlobRef.current], name, {
type: originalBlobRef.current.type,
});
const result = await compress(file, { ... });
}, []);
Skipping the fetch + decode step significantly improves the experience on mobile devices.
Pitfall 2: "No Response" When Switching Parameters
I made the image compression auto-trigger — dragging the quality slider or switching formats triggers an automatic re-compression after a 400ms debounce. But a strange problem occurred: when switching formats rapidly, the new operation seemed to have no effect.
After investigation, I found the cause: the previous compression was still executing asynchronously. Although the new parameter change cleared the debounce timer, the old doCompress was still running and would call setLoading(false) upon completion, overwriting the new state.
The solution is to introduce a requestId, incrementing the ID with each new compression. Old callbacks discard their results if the ID doesn't match:
const requestIdRef = useRef(0);
const doCompress = useCallback(async (name, q, fmt, maxW) => {
const id = ++requestIdRef.current;
setLoading(true);
const result = await compress(file, { ... });
// Key: Check if the ID is still the latest
if (id !== requestIdRef.current) return;
const reader = new FileReader();
reader.onload = () => {
if (id !== requestIdRef.current) return; // Check again
setCompressed({ src: reader.result, size: result.size });
setLoading(false);
};
reader.readAsDataURL(result);
}, []);
This is a universal "anti-race condition" pattern, applicable to any scenario involving asynchronous operations and state updates.
Pitfall 3: Page Flickering During Compression
When switching the quality slider, during the few seconds of compression, the previous compression result image was replaced by a spinner. The layout height jumped, causing the page to flicker.
The solution: Don't replace the image; overlay a mask on the old image:
<div className="relative">
{compressed ? (
<img src={compressed.src} className="..." />
) : (
<div className="w-full h-32 rounded" />
)}
{loading && (
<div className="absolute inset-0 flex items-center justify-center rounded bg-background/60">
<div className="h-5 w-5 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
)}
</div>
The old image remains displayed, a semi-transparent mask with a spinning loader is overlaid during compression, and it smoothly replaces upon completion. The visual is stable and flicker-free.
Pitfall 4: PNG Compression Results in a Larger File
A user reported that "compressing to PNG made the file larger." This isn't a bug; it's an inherent issue with the PNG format.
PNG is a lossless format. The process is:
- Original JPG (lossy, small file) → Decoded to a bitmap (full pixels)
- Bitmap → PNG encoding (lossless)
An 800KB JPG might decode to a 17MB bitmap, and after PNG compression, it's typically 2-5MB, much larger than the original JPG.
The solution is to clearly warn the user in the UI:
{format === "image/png" && (
<p className="text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
⚠️ PNG is a lossless format. Converting JPG/WebP to PNG usually results in a larger file size, not smaller.
</p>
)}
The button description is also changed to "Converting from JPG will increase size" to manage user expectations.
Design Detail: Side-by-Side Comparison + Lightbox Zoom
Users need to confirm the compression quality with their own eyes. A side-by-side comparison preview was implemented:
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-xl border p-3">
<p className="text-xs text-muted-foreground mb-2">Original · Click to enlarge</p>
<img
src={original.src}
className="cursor-zoom-in hover:opacity-90"
onClick={() => setLightbox({ src: original.src, label: `Original` })}
/>
</div>
<div className="rounded-xl border p-3">
<p className="text-xs text-muted-foreground mb-2">Compressed · Click to enlarge</p>
{/* ... */}
</div>
</div>
Clicking to enlarge uses a full-screen lightbox with a semi-transparent black background and a centered large image:
{lightbox && (
<div
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/80 p-4"
onClick={() => setLightbox(null)}
>
<p className="text-white text-sm mb-3 opacity-80">{lightbox.label}</p>
<img src={lightbox.src} className="max-w-full max-h-[80vh] object-contain rounded-lg shadow-2xl" />
<p className="text-white/50 text-xs mt-3">Click anywhere to close</p>
</div>
)}
Format Selection: Why WebP is Recommended
Four options are provided for the output format: Original Format / JPG / WebP / PNG.
Original Format —— Keeps the input format unchanged
JPG —— Lossy compression, suitable for photos
WebP —— 20-30% smaller than JPG (Recommended)
PNG —— Lossless, suitable for icons and screenshots (converting from JPG will increase size)
WebP is currently the most recommended format. All modern browsers support it, and at the same visual quality, it is 20-30% smaller than JPG. If your project is still using JPG, consider switching to WebP to save a quarter of your bandwidth immediately.
Complete Interaction Flow
Finally, let's outline the complete user flow:
- Drag and drop or click to upload an image; the original preview, resolution, and file size are displayed.
- Select the output format (defaults to original format, with JPG/WebP/PNG options).
- Drag the quality slider (10%-100%), or input a resolution limit (width/height will not exceed this value).
- After a 400ms debounce, compression triggers automatically. During compression, a mask and spinner overlay the old image.
- Upon completion, a side-by-side comparison is shown, displaying the compression ratio and size.
- Click either image to enlarge and check clarity.
- Click download when satisfied.
Throughout the entire process, the image is processed in the browser and never uploaded to any server.
Code
The complete code can be experienced at tools.memory.gd.cn/image/compress. The core logic is about 200 lines. It's not open-sourced, but the ideas are all laid out above.
If you also have image processing needs, consider trying a pure frontend solution — it saves servers, protects privacy, and is fast.
More Developer Tools: 👉 tools.memory.gd.cn, 18 tools, all free, no login required.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
Practical!
[Fist salute]