I got tired of uploading images to random converters online.
Not because they were slow — though they were — but because every time I dropped a file into one of those sites, I had absolutely no idea what was happening to it on the other end. The terms of service were always three pages long and vague in exactly the right places. "We may use uploaded content to improve our services." Sure.
So I built my own. And in the process, I learned that the browser is way more capable than most developers give it credit for.
Here's how it works under the hood.
The problem with server-side conversion
Most image converter tools follow the same architecture:
- You upload the file to their server
- Their backend runs ImageMagick (or something similar)
- The converted file gets written to their storage
- You download it
- They delete it... probably
The "probably" is the part I couldn't get past. And even setting aside the privacy angle, this approach has a real performance problem: you're bottlenecked by your upload speed, not your device's actual processing capability. A modern laptop can encode a JPEG in milliseconds. Waiting for a file to travel to a server and back just to do that makes no sense.
The alternative I wanted was simple: do all of it in the browser, on the user's own machine. No upload. No server. No wondering.
What the browser can actually do
Turns out, quite a lot.
Modern browsers expose a Canvas API that handles image encoding and decoding natively. The flow for a basic JPG → PNG conversion looks like this:
async function convertImage(file, targetFormat) {
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const blob = await canvas.convertToBlob({
type: `image/${targetFormat}`,
quality: 0.92,
});
return blob;
}
That's the whole thing. The file goes from the user's disk into browser memory, gets re-encoded, and comes back out as a download — without a single network request being made.
You can verify this yourself: open the network tab in DevTools while converting a file, and you'll see zero upload requests. That's not a policy. It's a technical reality.
Where it gets more interesting: HEIC
JPG, PNG, and WebP are straightforward because browsers handle them natively. HEIC is a different story.
HEIC (High Efficiency Image Container) is Apple's default photo format since iOS 11. It's genuinely good — better compression than JPG at equivalent quality, and it supports things like depth maps and Live Photos. The problem is that Windows and most web services don't know what to do with it. Upload a HEIC to half the sites on the internet and you get an error.
Browsers don't decode HEIC natively. So I needed a different approach.
The answer is WebAssembly.
import { libheif } from 'libheif-js';
async function decodeHEIC(file) {
const decoder = new libheif.HeifDecoder();
const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);
const images = decoder.decode(data);
const image = images[0];
const width = image.get_width();
const height = image.get_height();
const pixelData = await new Promise((resolve, reject) => {
image.display(
{ data: new Uint8ClampedArray(width * height * 4), width, height },
(result) => {
if (!result) reject(new Error('HEIC decode failed'));
else resolve(result);
}
);
});
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixelData.data, width, height);
ctx.putImageData(imageData, 0, 0);
return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
}
The WASM module (libheif-js) runs a compiled C++ HEIC decoder directly in the browser tab. It's doing real image processing — decoding a proprietary Apple format — using your CPU, inside your browser, without any server involvement.
The first time I got this working, it felt a bit absurd. You're essentially running native code in a browser tab. But that's what WebAssembly is for.
The performance reality
One thing I didn't expect: local processing is often faster than server-side, not just for privacy reasons.
For a typical 3–5 MB image, the Canvas-based conversion takes 100–300ms on a modern machine. The HEIC path via WASM is slower — more like 1–3 seconds for a large iPhone photo — but still faster than the upload + process + download cycle on a server, unless the user has a very fast connection.
The case where server-side wins is batch processing of many large files, where parallelization across server hardware matters. For one-off conversions, local wins.
What I learned about browser capabilities
Building this changed how I think about what belongs on a server.
A lot of tooling that gets built as "upload to our server, we'll process it" doesn't actually need to be that way. The browser has:
- A capable 2D Canvas API with built-in codec support
- WebAssembly for computationally intensive work
- The File API and FileReader for reading local files
- The Streams API for handling large files without loading everything into memory
The reflex to send files to a server is often habit, not necessity. For anything where the user already has the file locally and just needs it transformed, local processing is worth considering seriously.
Where this is going
The next format I'm adding is AVIF. The compression improvements over WebP are real — I've been testing it and for photographic content, you can get another 20–30% reduction in file size at equivalent quality. The challenge is that AVIF encoding is computationally heavier than WebP, so the WASM approach needs more careful optimization to avoid making users wait.
I'm also looking at batch conversion with a proper queue, and better handling of unusual color profiles (wide-gamut images from newer iPhones behave unexpectedly in some cases).
If you want to look at what this actually looks like in a working tool, I built it into imageconvert.website — the converter, HEIC tool, and compressor are all there. The code does exactly what I described above.
Happy to answer questions about any of the implementation details in the comments.
Top comments (0)