Back to all articles
case-study
🖼️

Building ImgSmash

A browser-based image compression tool that processes images locally using the Canvas API.

September 10, 2025 3 min read ...
Share:
CW

Cody Williamson

Senior Software Engineer

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!

Share:

Have a project in mind?

Whether you need full-stack development, cloud architecture consulting, or custom solutions—let's talk about how I can help bring your ideas to life.