Why Process Images Locally?
Every time you upload a photo to an online converter, you're trusting a stranger's server with your data. For casual use, that's fine. But for sensitive documents, personal photos, or professional work, there's a better way: do it all in the browser.
Modern browsers have everything needed for professional-grade image processing. The Canvas API gives you pixel-level control. WebAssembly lets you run compiled C/C++ image libraries at near-native speed. And the OffscreenCanvas API means you can do it all in a background thread without blocking the UI.
This is the architecture behind tools like ImageConv — no uploads, no analytics, 100% local processing.
Canvas API: Raw Pixel Access
The foundation of browser image processing is the Canvas API. Once you draw an image onto a canvas, you get direct access to every pixel:
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Load an image
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Get raw pixel data — RGBA, 4 bytes per pixel
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
// pixels[0] = R, pixels[1] = G, pixels[2] = B, pixels[3] = A
// Example: Convert to grayscale
for (let i = 0; i < pixels.length; i += 4) {
const gray = pixels[i] * 0.299 + pixels[i+1] * 0.587 + pixels[i+2] * 0.114;
pixels[i] = pixels[i+1] = pixels[i+2] = gray;
}
ctx.putImageData(imageData, 0, 0);
};
img.src = URL.createObjectURL(file);
This is fast enough for simple operations on small images. But for format conversion, compression, and batch processing, you need more power.
Format Conversion: PNG ↔ JPEG ↔ WebP ↔ AVIF
The Canvas API has built-in export support for PNG, JPEG, and WebP through canvas.toBlob():
// Convert to JPEG with quality control
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
// Download or display
}, 'image/jpeg', 0.85);
// Convert to WebP
canvas.toBlob(blob => {
// WebP typically produces 25-35% smaller files than JPEG
// at equivalent quality
}, 'image/webp', 0.80);
AVIF is a different story. Chrome 85 added AVIF decoding and Firefox 93 followed, but canvas.toBlob() and OffscreenCanvas.convertToBlob() still have no AVIF encoder in any browser. The call below does not throw: per spec an unsupported type falls back to image/png, so you get a file that looks fine and is several times larger than expected — a bug that survives code review because nothing errors.
// AVIF encoding is not available in canvas — this returns PNG
canvas.toBlob(blob => {
console.log(blob.type); // 'image/png'
}, 'image/avif', 0.75);
Real AVIF export means shipping an encoder — a WASM build of libavif or an equivalent — and paying for it in package size and encode time, which runs several times slower than WebP for the same image. That is why tools advertising AVIF output bundle a WASM encoder instead of calling the canvas. AVIF earns its cost on large photos where 30–50% smaller files matter; it is wasted on a 40 KB icon.
The Quality-Size Tradeoff
Different formats have very different compression characteristics:
- JPEG — Universal, fast, good for photos. Lossy only. No transparency.
- PNG — Lossless, supports transparency. Large files for photos.
- WebP — 25-35% smaller than JPEG. Supports transparency and animation. Good browser support.
- AVIF — 50%+ smaller than JPEG. Best quality-to-size ratio. Slower to encode.
The right choice depends on your use case. For web publishing, WebP is the sweet spot. For archival, PNG. For maximum compression, AVIF.
WebAssembly: Running C Libraries in the Browser
For advanced operations — lossless PNG optimization, progressive JPEG encoding, EXIF metadata manipulation — you need more than Canvas. WebAssembly lets you compile proven C libraries like libpng, mozjpeg, libwebp, and libavif to run directly in the browser:
// Load a WASM module (simplified example)
const wasmModule = await WebAssembly.instantiateStreaming(
fetch('image-processor.wasm'),
{ env: { memory: new WebAssembly.Memory({ initial: 256 }) } }
);
const { encode_png, encode_jpeg, optimize_png } = wasmModule.instance.exports;
// Use the WASM functions
const pngOutput = encode_png(pixelData, width, height, compressionLevel);
const jpegOutput = encode_jpeg(pixelData, width, height, quality);
The advantage over pure JavaScript: WASM image encoding is 3-10x faster for complex operations. mozjpeg's trellis quantization, for example, involves heavy computation that would be painfully slow in JS but runs at native speed in WASM.
OffscreenCanvas: Background Processing
In a browser extension context (Manifest V3), the Service Worker has no DOM access. This is where OffscreenCanvas becomes essential. It lets you create a canvas in an offscreen document and process images without a visible UI:
// In a Manifest V3 extension's offscreen document
const offscreen = new OffscreenCanvas(width, height);
const ctx = offscreen.getContext('2d');
// Draw the source image
ctx.drawImage(sourceImage, 0, 0);
// Process pixels
const imageData = ctx.getImageData(0, 0, width, height);
// ... pixel manipulation ...
// Export to desired format
const blob = await offscreen.convertToBlob({
type: 'image/webp',
quality: 0.85
});
This is the exact pattern used by ImageConv for format conversion, and by PageShot for screenshot processing. All image data stays in memory — nothing touches the network.
Batch Processing & Memory Management
When processing multiple images, memory management becomes critical. A single 4K image at RGBA takes 33MB of RAM. Processing 50 images simultaneously would consume 1.6GB — enough to crash most browser tabs.
The solution is a sequential pipeline with explicit cleanup:
async function processBatch(files, options) {
const results = [];
for (const file of files) {
// Process one at a time
const result = await processImage(file, options);
results.push(result);
// Force garbage collection of intermediate data
// by nullifying references
if (global.gc) global.gc();
}
return results;
}
async function processImage(file, options) {
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
bitmap.close(); // Release the bitmap immediately
// ... process ...
const blob = await canvas.convertToBlob({
type: options.format,
quality: options.quality
});
return blob;
}
Key techniques: bitmap.close() to release image memory immediately, processing one image at a time to limit peak memory, and using OffscreenCanvas to avoid DOM overhead.
Resize & Rescale Algorithms
Image resizing in Canvas uses bilinear interpolation by default, which produces decent but not great results. For better quality downsizing, you can use the step-down technique:
// Instead of resizing from 4000px to 400px directly,
// step down in halves: 4000 → 2000 → 1000 → 500 → 400
function highQualityResize(source, targetWidth, targetHeight) {
let current = source;
while (current.width > targetWidth * 2) {
const halfW = Math.ceil(current.width / 2);
const halfH = Math.ceil(current.height / 2);
const step = new OffscreenCanvas(halfW, halfH);
const stepCtx = step.getContext('2d');
stepCtx.drawImage(current, 0, 0, halfW, halfH);
current = step;
}
const final = new OffscreenCanvas(targetWidth, targetHeight);
const finalCtx = final.getContext('2d');
finalCtx.drawImage(current, 0, 0, targetWidth, targetHeight);
return final;
}
This produces much sharper results than a single-step resize, especially for significant downscaling (e.g., 4K → thumbnail).
EXIF Metadata Handling
JPEG files contain EXIF metadata — camera info, GPS coordinates, timestamps, thumbnail previews. When converting formats, you need to decide: preserve, strip, or selectively copy metadata.
Stripping EXIF is a privacy win (GPS coordinates in photos can reveal your home address). Preserving it is important for photographers. A good image tool gives you the choice.
In WASM, you can use exiftool compiled to WebAssembly to read and write EXIF data across formats. The binary is about 2MB — small enough to bundle with a browser extension.
Performance Benchmarks
Real-world performance on a mid-range laptop (Intel i5, 8GB RAM):
- Canvas toBlob JPEG: ~50ms for a 2000×1500 image
- Canvas toBlob WebP: ~120ms for the same image
- Canvas toBlob AVIF: ~800ms (encoding is significantly slower)
- WASM mozjpeg: ~80ms with better compression than native Canvas JPEG
- WASM libwebp: ~90ms with more quality options than native Canvas WebP
For batch processing 100 images, the bottleneck is usually memory, not CPU. Sequential processing with proper cleanup keeps memory under 200MB even for large batches.
Building an Image Processing Extension
If you want to build your own image processing browser extension, here's the architecture I use in ImageConv:
- Service Worker — handles extension lifecycle, message routing, and context menu integration
- Offscreen Document — runs the actual image processing (Canvas + WASM)
- Popup UI — format selection, quality slider, batch controls
- Content Script — for right-click "convert this image" on any webpage
The key insight: never process images in the Service Worker. It has no DOM, limited memory, and can be terminated at any time. Always delegate to an Offscreen Document where you have stable access to Canvas and WebAssembly.
The Future is Local
Browser-based image processing has reached a tipping point. With WebAssembly for performance, OffscreenCanvas for background processing, and modern format support (WebP, AVIF), there's no longer a technical reason to send your images to a server.
The benefits are clear: privacy (your images never leave your device), speed (no upload/download round-trips), and reliability (works offline, no server dependencies).
Whether you use an existing tool like ImageConv or build your own, the message is the same: the browser is no longer just a document viewer. It's a capable image processing platform.