Why
I wanted to compress images without uploading them to a random website. Online compressors work but your images end up on someone else’s server. For client work and personal photos I didn’t like the tradeoff, so I built something small that runs entirely in the browser.
How It Works
Drop an image, it gets processed with createImageBitmap and Canvas, encoded to WebP, and downloaded. No server round trip.
export async function convertToWebP(file: File, quality: number): Promise<ConversionInfo> {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d')!.drawImage(bitmap, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Failed to convert to WebP'))),
'image/webp',
quality,
);
});
// browsers without WebP encode support silently fall back to PNG
const isWebP = blob.type === 'image/webp';
return { blob, isWebP, fallbackFormat: isWebP ? undefined : blob.type };
}
A few things worth noting. createImageBitmap is async-native, so you skip the Image() + onload dance and it handles more formats including HEIC on browsers that support it. Quality is a decimal 0–1. Browsers that can’t encode WebP quietly return PNG, so checking blob.type is how you know if that happened.
The “Output Bigger Than Input” Problem
Sometimes WebP produces a larger file than the source — usually with already-optimized PNGs or very small images. Instead of failing, the tool regenerates candidates at a few different quality levels and lets you pick:
if (conversionResult.blob.size >= item.file.size) {
await addOversizeCandidates(item, quality.value);
continue;
}
This turned out to matter more than I expected. The first version just threw an error, which was confusing. Showing alternatives is more useful.
State and Batching
State lives in a couple of focused composables instead of Pinia — useFileManager handles selection/validation, useImageConverter runs the conversion loop. Batch downloads are client-side too via JSZip:
const zip = new JSZip();
for (const result of results) {
const buf = await (await fetch(result.url)).arrayBuffer();
zip.file(result.downloadName, buf);
}
return zip.generateAsync({ type: 'blob' });
The One Server Feature
There’s a global conversion counter — just a number, no image data. It’s a Netlify Edge Function backed by Netlify Blobs:
const store = getStore({ name: 'softie-stats', consistency: 'strong' });
if (request.method === 'POST') {
const { amount } = await request.json();
const current = await store.get('totalConversions', { type: 'text' });
const next = Number(current ?? '0') + amount;
await store.set('totalConversions', String(next));
return jsonResponse({ total: next });
}
Counts only. Images never leave the browser.
Stack
Vue 3 with <script setup>, TypeScript strict, Vite. @vueuse/motion for animations, JSZip for archives.
Takeaways
The browser’s native APIs (createImageBitmap, Canvas, File API) are capable enough that I didn’t pull in any image processing libraries. Composables handled state fine — no Pinia needed for something this focused. And the oversize edge case was the most important UX detail in the whole project.
Source: github.com/codywilliamson/imgsmash.
Enjoyed this article? Share it with others!