Most image compression tutorials assume you're running something server-side — Sharp, ImageMagick, a Node backend. This one doesn't need any of that. We're going to build a working image compressor that runs entirely in the browser, using nothing but the Canvas API.
By the end, you'll have a single HTML file that takes an image, compresses it, and lets the user download the result — no server, no dependencies, no build step.
What We're Building
A simple page where a user can:
- Upload an image
- Adjust a quality slider
- See the compressed file size update live
- Download the compressed result ## Step 1: The HTML Skeleton
<!DOCTYPE html>
<html>
<head>
<title>Image Compressor</title>
</head>
<body>
<input type="file" id="fileInput" accept="image/*">
<br><br>
<label>Quality: <span id="qualityValue">80</span>%</label>
<input type="range" id="qualitySlider" min="1" max="100" value="80">
<br><br>
<canvas id="canvas" style="max-width: 400px; display: none;"></canvas>
<p id="sizeInfo"></p>
<a id="downloadLink" style="display: none;">Download Compressed Image</a>
<script src="compress.js"></script>
</body>
</html>
Nothing fancy — a file input, a quality slider, a canvas to do the actual work, and a download link that appears once we have a result.
Step 2: Loading the Image Into Canvas
Canvas can't compress a file directly — it works with pixels. So the first job is getting the uploaded image drawn onto a canvas:
const fileInput = document.getElementById('fileInput');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let currentImage = null;
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const bitmap = await createImageBitmap(file);
currentImage = bitmap;
canvas.width = bitmap.width;
canvas.height = bitmap.height;
ctx.drawImage(bitmap, 0, 0);
canvas.style.display = 'block';
compressAndPreview();
});
createImageBitmap is the modern way to decode an image file — it's faster than the older Image() + onload pattern and works well with async/await.
If you need to support older browsers that don't implement createImageBitmap, you can fall back to using an Image element with an onload handler:
function loadImageFallback(file) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
Both approaches draw onto the canvas the same way afterward — ctx.drawImage(bitmap, 0, 0) works whether bitmap came from createImageBitmap or this fallback.
Step 3: Compressing With toBlob
This is the actual compression step. canvas.toBlob() takes a format and a quality value (0 to 1) and re-encodes the canvas content:
const qualitySlider = document.getElementById('qualitySlider');
const qualityValue = document.getElementById('qualityValue');
const sizeInfo = document.getElementById('sizeInfo');
const downloadLink = document.getElementById('downloadLink');
function compressAndPreview() {
if (!currentImage) return;
const quality = qualitySlider.value / 100;
canvas.toBlob((blob) => {
const sizeKB = (blob.size / 1024).toFixed(1);
sizeInfo.textContent = `Compressed size: ${sizeKB} KB`;
const url = URL.createObjectURL(blob);
downloadLink.href = url;
downloadLink.download = 'compressed.jpg';
downloadLink.textContent = `Download (${sizeKB} KB)`;
downloadLink.style.display = 'inline';
}, 'image/jpeg', quality);
}
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = qualitySlider.value;
compressAndPreview();
});
That's the entire core mechanism. Move the slider, toBlob re-encodes at the new quality, and the size updates live.
Step 4: A Small but Important Fix — Memory Cleanup
Every time compressAndPreview() runs, it creates a new object URL with URL.createObjectURL(). If you don't release the old one, you leak memory — this adds up fast if someone drags the slider around for a while:
let previousUrl = null;
function compressAndPreview() {
if (!currentImage) return;
const quality = qualitySlider.value / 100;
canvas.toBlob((blob) => {
if (previousUrl) URL.revokeObjectURL(previousUrl);
const sizeKB = (blob.size / 1024).toFixed(1);
sizeInfo.textContent = `Compressed size: ${sizeKB} KB`;
const url = URL.createObjectURL(blob);
previousUrl = url;
downloadLink.href = url;
downloadLink.download = 'compressed.jpg';
downloadLink.textContent = `Download (${sizeKB} KB)`;
downloadLink.style.display = 'inline';
}, 'image/jpeg', quality);
}
Small detail, easy to miss, and exactly the kind of thing that turns into a real memory leak if this code ever ships in something long-running.
What This Doesn't Handle (On Purpose)
This tutorial deliberately stops at "quality slider compression" because that's enough to actually understand the mechanism. A few things it leaves out, if you want to take it further:
-
Target file size instead of quality — this needs a search loop around
toBlob(binary search on quality, falling back to resizing dimensions when quality bottoms out) rather than a direct slider -
PNG support —
toBlobworks for PNG too, but PNG is lossless, so "quality" doesn't apply the same way; you'd be resizing or reducing color depth instead -
Large image performance — very large images (many megapixels) can make
toBlobnoticeably slow; moving this to a Web Worker keeps the UI responsive during compression ## Next Challenges
Once you've built this version, try extending it with:
- Drag-and-drop uploads
- Batch compression
- WebP output
- AVIF output
- Exact target-size compression
- EXIF orientation handling ## Try It Yourself
Paste both snippets into an HTML file and open it in a browser — no build tools, no npm install, nothing to configure. That's the whole appeal of doing this client-side: it's genuinely just HTML, CSS, and the Canvas API doing real work.
If you want to see a fuller version of this same idea — target-size compression, PDF tools, and 70+ other browser-based utilities built on the same no-backend approach — I've been building ResizeHub using exactly this pattern, extended a lot further.

Top comments (0)