DEV Community

lane
lane

Posted on Originally published at datashifttools.com

No-Upload File Conversion in the Browser: An Architecture Walkthrough

Most "free online converters" upload your file to a server, convert it there, and hand you a download link. If you've ever converted a client mockup, a signed contract, or an unreleased screenshot, that's a quiet problem: your file passes through infrastructure you don't control, usually with a "we delete it after 24 hours" promise you can't verify.

I wanted a converter I could actually trust for that kind of file, so I built one that never uploads anything. Along the way I hit a few non-obvious problems — CSP and HEIC, EXIF rotation, worker timeouts, and target-size compression. Here's what I learned.

"No upload" should be verifiable, not a marketing claim

The nice thing about a fully client-side converter is that you can check the claim in ten seconds:

  1. Open the tool.
  2. Open DevTools → Network and tick Preserve log.
  3. Drop in a file and convert it.
  4. Filter by Fetch/XHR.

You won't see a request carrying your file — only the initial page assets. That's possible because the deployed artifact is a static bundle of HTML, CSS, JS and WASM on a CDN. There is no upload endpoint to log, because there is no backend.

The image pipeline

Conversion is decode → draw → encode.

Decode with createImageBitmap(blob, { imageOrientation: 'from-image' }). That applies the EXIF rotation, so portrait phone photos don't come out sideways. Some browsers ignore the option, so I fall back to the plain call.

Draw to an offscreen canvas. For JPEG output, fill the canvas white first. JPEG has no alpha channel, and without that fill, transparent pixels render black.

Encode the pixels. The tempting API is canvas.toBlob(), but it produces poor JPEGs — often larger than the input. Instead I send the pixels to a Web Worker running jSquash: mozjpeg, libwebp and oxipng compiled to WebAssembly. The output matches dedicated desktop optimizers, and the main thread stays free.

const imageData = ctx.getImageData(0, 0, width, height);
worker.postMessage({ imageData, format, quality }, [imageData.data.buffer]);
Enter fullscreen mode Exit fullscreen mode

Two details that matter at scale

Zero-copy transfer. Note the second argument to postMessage — the pixel buffer goes in the Transferable list, so a 40-megapixel image isn't structurally cloned across the thread boundary.

Timeouts must kill the worker. A very large image can exceed an encode budget. Rejecting the pending promise alone leaves the worker busy, so every later file in the batch queues behind it. On timeout I terminate the worker, reject all pending requests, and lazily rebuild it for the next file.

HEIC is the hard one

Browsers can't decode HEIC outside Safari. The fix is libheif compiled to WebAssembly — but there's a trap under a strict Content-Security-Policy.

The common heic-to entry point relies on new Function, which requires unsafe-eval and fails in production. The heic-to/csp build omits eval, so the only relaxation needed is wasm-unsafe-eval. Because it's a dynamic import(), the decoder never lands in the initial bundle — pages that don't convert HEIC pay nothing for it.

Compression that actually hits a target size

A quality slider is easy. "Compress this to 200 KB" is harder, because there's no formula. The tool binary-searches the quality: encode at a mid value, compare to the target, narrow the range, and keep the highest quality that still fits. Each pass needs a fresh copy of the pixel buffer (transferring detaches the original). Around five iterations is enough — good precision without re-encoding a large image a dozen times.

Editor UIs should degrade, not freeze

The JSON and YAML tools highlight syntax and render a line-number gutter — one node per token or per line. On a multi-megabyte file that locks the main thread. Above hard thresholds the view silently falls back to plain text, and the node counter short-circuits as soon as it passes the limit, so oversized documents aren't fully traversed just to be rejected.

Why bother

Privacy is the headline: files never leave the device, so there's no retention policy to trust. But it's also faster — no upload round-trip — and it keeps working on a slow or flaky connection.

Try it / verify it

If you want to poke at it (or run the Network-tab check yourself):

If you've built something similar, I'd be curious how you handled HEIC + CSP, or whether you found a better way to hit a target file size than binary search. Happy to compare notes in the comments.

Top comments (0)