How I Built an Image Converter That Literally Cannot See Your Files
Source: Dev.to
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.
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. 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.
Tip: Open the Network tab in DevTools while converting a file; 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 (High Efficiency Image Container) is a different story.
- HEIC is Apple’s default photo format since iOS 11.
- It offers better compression than JPG at equivalent quality and supports depth maps, Live Photos, etc.
- 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: 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 real image processing—decoding a proprietary Apple format—using the user’s CPU, 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 exactly 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.
| Scenario | Typical time (modern machine) |
|---|---|
| Canvas‑based JPG → PNG (3–5 MB) | 100–300 ms |
| HEIC → JPEG via WASM (large iPhone photo) | 1–3 s |
| Server‑side (upload + process + download) | > 3 s (depends on connection) |
The case where server‑side wins is batch processing of many large files, where parallelisation 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 2‑D 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—testing shows a 20–30 % reduction in file size at equivalent quality for photographic content. The challenge is that AVIF encoding is computationally heavier than WebP, but with the same browser‑side approach (Canvas + WASM) it can still be done locally without ever leaving the user’s machine.
Overview
The WASM approach needs more careful optimization to avoid making users wait.
Current Work
- Batch conversion with a proper queue.
- Better handling of unusual color profiles (e.g., wide‑gamut images from newer iPhones that behave unexpectedly in some cases).
Demo
If you want to see this in action, check out the live tool I built at imageconvert.website.
The site includes:
- The converter
- An HEIC tool
- A compressor
All of these components implement the workflow described above.
Feel free to ask any questions about the implementation details in the comments!