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 6 min read
Share:
CW

Cody Williamson

Senior Software Engineer

The Problem

I wanted to compress images without uploading them to some random website. Online compression tools work fine, but your images end up on someone else’s server, and I didn’t like that tradeoff for personal photos or client work.

The Solution

ImgSmash runs entirely in the browser. When you drop an image, it gets processed using the createImageBitmap API and Canvas for WebP encoding. The compressed image is generated locally and downloaded directly—nothing goes to a server.

Technical Implementation

Architecture Overview

The app follows a clean separation of concerns using Vue 3’s Composition API:

src/
├── components/     # UI components (DropZone, FileCard, ResultCard, etc.)
├── composables/    # Business logic and state management
├── utils/          # Pure utility functions
├── constants/      # Configuration and error messages
└── types/          # TypeScript interfaces

The Core Conversion Engine

The main conversion logic is in imageConverter.ts. It uses createImageBitmap and Canvas to do the encoding:

export async function convertToWebP(file: File, quality: number): Promise<ConversionInfo> {
  // createImageBitmap is faster than loading via Image() element
  const bitmap = await createImageBitmap(file);
  
  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;

  const context = canvas.getContext('2d');
  context.drawImage(bitmap, 0, 0);

  const blob = await new Promise<Blob>((resolve, reject) => {
    canvas.toBlob(
      (resultBlob) => {
        if (resultBlob) resolve(resultBlob);
        else reject(new Error('Failed to convert to WebP'));
      },
      'image/webp',
      quality,
    );
  });

  // Detect browser fallback behavior
  const isWebP = blob.type === 'image/webp';
  
  return {
    blob,
    isWebP,
    fallbackFormat: isWebP ? undefined : blob.type,
  };
}

A few notes on this approach:

  • createImageBitmap is async-native, so you don’t need to deal with onload callbacks like you would with Image(). It also handles more formats, including HEIC on supported browsers.
  • Some browsers fall back to PNG silently if they can’t do WebP. I check blob.type to detect when that happens.
  • The Canvas API expects quality as a decimal between 0 and 1, so that’s what the slider controls.

Handling the “Output Larger Than Input” Problem

One tricky edge case: sometimes WebP encoding actually produces a larger file than the original—especially with already-optimized PNGs or very small images. Instead of just failing, ImgSmash offers alternatives:

if (conversionResult.blob.size >= item.file.size) {
  // Don't add to results - instead generate alternative quality options
  await addOversizeCandidates(item, quality.value);
  continue;
}

The generateWebPCandidates function creates multiple versions at different quality levels, letting users pick the best tradeoff:

export async function generateWebPCandidates(
  file: File,
  qualities: number[],
): Promise<CandidateOption[]> {
  const bitmap = await createImageBitmap(file);
  // ... canvas setup ...
  
  for (const quality of sortedQualities) {
    const blob = await canvasToBlob(canvas, 'image/webp', quality);
    candidates.push({
      quality,
      size: blob.size,
      url: URL.createObjectURL(blob),
      // ... other metadata
    });
  }
  
  return candidates;
}

State Management with Composables

Instead of reaching for Vuex or Pinia, the app uses focused composables for state management:

useFileManager handles file selection, validation, and cleanup:

export function useFileManager() {
  const selectedFiles = reactive<FileItem[]>([]);
  
  function addFiles(fileList: FileList): AddFilesResult {
    // Validate file types
    const accepted = incoming.filter(isAcceptedImageType);
    
    // Enforce size limits (20MB per file)
    const eligible = accepted.filter(
      file => file.size <= GUARDRAILS.MAX_FILE_SIZE_BYTES
    );
    
    // Enforce batch limits (10 files max)
    const filesToAdd = eligible.slice(0, remainingSlots);
    
    // Create preview URLs and add to state
    for (const file of filesToAdd) {
      selectedFiles.push({
        id: generateFileId(file),
        file,
        previewUrl: URL.createObjectURL(file),
      });
    }
    
    return result;
  }
}

useImageConverter manages the conversion process and results:

export function useImageConverter() {
  const results = reactive<ConversionResult[]>([]);
  const isConverting = ref(false);
  const quality = ref(0.8); // Default 80% quality
  const progress = ref(0);
  
  async function convertImages(files: FileItem[]) {
    for (const item of files) {
      const result = await convertToWebP(item.file, quality.value);
      // ... handle results, update progress
    }
  }
}

Batch Downloads with JSZip

When users convert multiple images, they probably want to download them all at once. ImgSmash uses JSZip to create archives client-side:

import JSZip from 'jszip';

export async function createResultsArchive(results: ConversionResult[]): Promise<Blob> {
  const zip = new JSZip();

  for (const result of results) {
    const response = await fetch(result.url);
    const arrayBuffer = await response.arrayBuffer();
    zip.file(result.downloadName, arrayBuffer);
  }

  return zip.generateAsync({ type: 'blob' });
}

This fetches from the blob URLs we created earlier and packages everything into a downloadable ZIP—still entirely client-side.

Global Stats (The One Server Feature)

ImgSmash has one optional server feature: a global conversion counter. It’s implemented as a Netlify Edge Function using Netlify Blobs for persistence:

// netlify/edge-functions/stats.ts
import { getStore } from '@netlify/blobs';

export default async function handler(request: Request): Promise<Response> {
  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 });
  }

  const current = await store.get('totalConversions', { type: 'text' });
  return jsonResponse({ total: Number(current ?? '0') });
}

Importantly, this only tracks counts—no image data ever touches the server.

Guardrails and Configuration

All limits are centralized in constants/config.ts:

export const IMAGE_CONFIG = {
  ACCEPTED_TYPES: ['image/png', 'image/jpeg', 'image/jpg', 'image/heic', 'image/webp'],
  DEFAULT_QUALITY: 0.8,
  QUALITY_MIN: 0.1,
  QUALITY_MAX: 1,
};

export const GUARDRAILS = {
  MAX_FILES_PER_BATCH: 10,
  MAX_FILE_SIZE_BYTES: 20 * 1024 * 1024, // 20 MB
  MAX_SESSION_CONVERSIONS: 100,
};

The session limit prevents abuse without requiring authentication—it’s stored in component state and resets on page refresh.

The Tech Stack

The app is built with Vue 3 (Composition API with <script setup>), TypeScript in strict mode, and Vite. I used @vueuse/motion for animations and JSZip for client-side zip file creation. It’s hosted on Netlify with an Edge Function for the stats counter.

How It Turned Out

The tool works well for what I needed. Images get processed in milliseconds since it’s all happening locally with native browser APIs. The one server interaction is the conversion counter (which only tracks counts, not images). The app also works offline once it’s loaded, since the core functionality doesn’t need the network.

Things I Learned

Building this reinforced a few things for me. The browser’s native APIs (createImageBitmap, Canvas, File API) are capable of handling image processing without external libraries—I didn’t need to pull in any heavy image manipulation packages. Vue composables were enough for state management here; I didn’t need Pinia or Vuex for a focused app like this. And handling the edge case where the output is larger than the input turned out to be more important than I initially thought—offering alternative quality levels made that situation less confusing for users.

The source code is on GitHub.

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.