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:
createImageBitmapis async-native, so you don’t need to deal withonloadcallbacks like you would withImage(). 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.typeto 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!