9 Browser-Based Image Tools, One Architecture: Canvas API, WebAssembly, and Zero Uploads
I built 9 image tools for UltimateTools — compress, convert, resize, crop, rotate, blur, watermark, HTML-to-image, and YouTube thumbnail downloader. Every tool runs entirely in the browser. No server processing, no file uploads, no temporary storage.
Here's the architecture that makes this work, and the specific browser APIs behind each tool.
The shared foundation
Every image tool follows the same pipeline:
File input (drag-and-drop or file picker)
↓
Load into HTMLImageElement or Canvas
↓
Transform (tool-specific logic)
↓
Export as Blob (toBlob / toDataURL)
↓
Download (createObjectURL → anchor click)
The loading and downloading code is shared. What changes per tool is the transform step.
Loading images
const loadImage = (file: File): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
};
URL.createObjectURL is faster than FileReader.readAsDataURL for large files — it creates a reference to the file in memory without base64-encoding it.
Downloading results
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
Every tool uses this pattern. No server round-trip.
Tool 1: Image Compressor
API: canvas.toBlob(callback, mimeType, quality)
The quality parameter (0 to 1) controls JPEG compression. At 0.8, you typically get 60–80% size reduction with no visible quality loss.
const compress = async (file: File, quality: number): Promise<Blob> => {
const img = await loadImage(file);
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d')!.drawImage(img, 0, 0);
return new Promise(resolve => {
canvas.toBlob(blob => resolve(blob!), 'image/jpeg', quality);
});
};
For PNG, the quality parameter is ignored — PNG is lossless. But re-encoding through Canvas still strips unnecessary metadata, giving 20–40% reduction on typical screenshots.
Batch processing (up to 20 images) runs sequentially to avoid memory spikes. ZIP download uses JSZip.
Tool 2: Image Converter
API: Same toBlob — just change the MIME type.
const convert = async (file: File, targetFormat: string): Promise<Blob> => {
const img = await loadImage(file);
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d')!.drawImage(img, 0, 0);
const mimeType = `image/${targetFormat}`; // jpeg, png, or webp
return new Promise(resolve => {
canvas.toBlob(blob => resolve(blob!), mimeType, 0.92);
});
};
Supported conversions: JPG ↔ PNG ↔ WEBP. The Canvas API handles input format detection automatically — you just change the output MIME type.
One gotcha: converting a transparent PNG to JPEG fills the alpha channel with black. The fix is to draw a white rectangle before drawing the image:
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
Tool 3: Image Resizer
API: Canvas dimensions + drawImage scaling.
const resize = async (
file: File,
targetWidth: number,
targetHeight: number
): Promise<Blob> => {
const img = await loadImage(file);
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
canvas.getContext('2d')!.drawImage(img, 0, 0, targetWidth, targetHeight);
return new Promise(resolve => {
canvas.toBlob(blob => resolve(blob!), file.type, 0.92);
});
};
The user picks width/height or a percentage. Aspect ratio lock is a UI toggle — when enabled, changing width auto-calculates height:
const newHeight = Math.round(targetWidth / aspectRatio);
Tool 4: Image Crop
API: drawImage with source rectangle parameters.
drawImage has an overload that takes source coordinates — this is the crop:
ctx.drawImage(
img,
sx, sy, sWidth, sHeight, // source rectangle (crop area)
0, 0, sWidth, sHeight // destination (full canvas)
);
The crop selection UI uses a draggable overlay with resize handles. The user drags to select an area, and the coordinates map directly to the drawImage source parameters.
Aspect ratio presets (1:1, 16:9, 4:3, 3:2) constrain the selection rectangle.
Tool 5: Rotate Image
API: Canvas 2D transforms — translate + rotate.
const rotateImage = async (file: File, degrees: number): Promise<Blob> => {
const img = await loadImage(file);
const radians = (degrees * Math.PI) / 180;
// Swap dimensions for 90/270 degree rotations
const swap = degrees === 90 || degrees === 270;
const canvas = document.createElement('canvas');
canvas.width = swap ? img.naturalHeight : img.naturalWidth;
canvas.height = swap ? img.naturalWidth : img.naturalHeight;
const ctx = canvas.getContext('2d')!;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(radians);
ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2);
return new Promise(resolve => {
canvas.toBlob(blob => resolve(blob!), file.type, 0.92);
});
};
The dimension swap for 90° and 270° rotations is easy to forget — a 1920x1080 image rotated 90° becomes 1080x1920. Without swapping, the image gets clipped.
Tool 6: Blur Image
API: Canvas filter property.
const blurImage = async (file: File, radius: number): Promise<Blob> => {
const img = await loadImage(file);
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d')!;
ctx.filter = `blur(${radius}px)`;
ctx.drawImage(img, 0, 0);
return new Promise(resolve => {
canvas.toBlob(blob => resolve(blob!), file.type, 0.92);
});
};
The filter property on Canvas2D works like CSS filters. The blur radius is adjustable via a slider. One line of code for the actual blur — the rest is UI.
Tool 7: Watermark Image
API: Canvas text rendering — fillText with transforms.
const addWatermark = (ctx: CanvasRenderingContext2D, text: string, options: WatermarkOptions) => {
ctx.save();
ctx.globalAlpha = options.opacity;
ctx.fillStyle = options.color;
ctx.font = `${options.fontSize}px ${options.fontFamily}`;
ctx.translate(options.x, options.y);
ctx.rotate((options.rotation * Math.PI) / 180);
ctx.fillText(text, 0, 0);
ctx.restore();
};
The tool supports:
- Custom text, font size, color, and opacity
- Position (center, corners, or drag to place)
- Rotation angle
- Tiled/repeated watermark mode (draws the text in a grid pattern)
Tiled mode uses nested loops to fill the canvas:
for (let y = -canvas.height; y < canvas.height * 2; y += spacing) {
for (let x = -canvas.width; x < canvas.width * 2; x += spacing) {
addWatermark(ctx, text, { ...options, x, y });
}
}
The negative start and 2x overshoot ensures coverage even when the text is rotated.
Tool 8: HTML to Image
API: html2canvas library.
This one can't use the native Canvas API alone — rendering arbitrary HTML requires a full layout engine. html2canvas re-implements CSS rendering on a Canvas element:
import html2canvas from 'html2canvas';
const captureHtml = async (element: HTMLElement): Promise<Blob> => {
const canvas = await html2canvas(element, {
scale: 2, // 2x for retina quality
useCORS: true, // handle cross-origin images
backgroundColor: null, // transparent background
});
return new Promise(resolve => {
canvas.toBlob(blob => resolve(blob!), 'image/png');
});
};
The user types or pastes HTML/CSS in an editor, sees a live preview, and exports it as PNG or JPEG. Useful for social media cards, code snippets, or styled text.
Tool 9: YouTube Thumbnail Downloader
API: YouTube's predictable thumbnail URL pattern.
This is the only tool that doesn't use Canvas at all. YouTube stores thumbnails at known URLs:
const getThumbnailUrls = (videoId: string) => ({
maxres: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
hq: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
mq: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`,
sd: `https://img.youtube.com/vi/${videoId}/sddefault.jpg`,
});
The video ID is extracted from the URL using a regex that handles all YouTube URL formats — youtube.com/watch?v=, youtu.be/, youtube.com/embed/, etc.
The download uses a server-side proxy route because YouTube images don't set CORS headers — you can't fetch them directly from the browser and trigger a download. The proxy fetches the image and returns it with the correct Content-Disposition header.
Why client-side matters
Every image tool that processes files (8 out of 9) runs entirely in the browser:
- Privacy: Files never leave the device. No server logs, no temporary storage.
- Speed: No upload/download latency. A 10MB image compresses in under a second.
- Cost: No server compute costs for image processing.
- Offline: Once loaded, the tools work without internet (except YouTube thumbnails).
The trade-off is that heavy processing (like advanced PNG optimization with pngquant) requires server-side or WebAssembly. For most use cases — compress, convert, resize, crop, rotate, blur, watermark — the Canvas API is more than enough.
The full suite
All 9 tools are live at ultimatetools.io/tools/image-tools/. Everything is free, no account required.
The same architecture pattern (load → Canvas → transform → download) powers all of them. If you're building browser-based tools, the Canvas API is remarkably capable — most image operations are 5-10 lines of actual processing code wrapped in UI.
Questions about any of these implementations? Drop them in the comments.
Top comments (0)