The 2.9 MiB decoder: what your browser actually downloads

One file does the entire job. Here is how big it really is over the wire, how fast the page is ready on a second visit, and what happens if that file never arrives.

679,055 bytes over the wire Second visit: 307 ms, 0 bytes Fetched once, then cached

Short answer: one JavaScript file, heic-to.js. Uncompressed it is 2,999,737 bytes; what actually crosses the network is 679,055 bytes of Brotli-compressed body (679,355 bytes on the wire including headers) — a compression ratio of 4.42×. Your browser fetches it on the first visit and keeps it: on the second visit it transferred 0 bytes and the page was ready in 307 ms, against 1,939 ms on the cold first load.

What I measured on this site: three experiments, all today. A cold load in a brand-new browser profile; a second visit with the cache warm; and a load where I blocked that one file at the network layer. Blocked, the page still renders and the upload box still works, but window.HeicTo never exists and dropping a file in leaves the spinner turning with no error message — I waited six seconds and nothing came back.

What the download is

MeasureValue
Uncompressed size2,999,737 bytes
Compressed body (Brotli)679,055 bytes
On the wire, headers included679,355 bytes
Compression ratio4.42×
Time to fetch it (cold)407 ms
Whole first page load, measured separately688,921 bytes across 6 requests

So the number in the headline is the unpacked size — what the browser has to parse, not what you pay for in data. Nearly the whole first-load weight is this one file: 679,355 of the 688,921 bytes.

Measured: first visit versus second visit

First visit (cold profile)Second visit (cache warm)
Time until the converter is ready1,939 ms307 ms
Decoder bytes transferred679,3550
Decoder fetch time407 ms0 ms
Decoded size in memory2,999,737 bytes2,999,737 bytes

The server’s own headers say why: Cache-Control: public, max-age=14400, must-revalidate — four hours — plus an ETag and Content-Encoding: br. I read those headers off the real response; I did not wait four hours to watch the revalidation happen, so treat the four hours as what the server promises rather than something I observed.

This is also the mechanism behind the offline behaviour measured in the offline test: once the file is in the browser’s cache, no network is needed to convert.

Measured: what happens if it never arrives

I blocked the request for heic-to.js at the network layer and loaded the page again:

That is worth knowing as a diagnostic: if the page sits on its spinner forever instead of telling you something is wrong, the decoder is the first suspect — a first visit with no connection, a content blocker, or a proxy that swallowed one large JavaScript file. Everything else about the page can be perfectly healthy.

What is inside the file

Reading the 2,999,737 bytes rather than guessing:

Found in the fileCountWhat it tells you
WebAssembly API references0Nothing asks the browser for WASM at runtime
isWasm2js2An Emscripten build: libheif was compiled to WebAssembly, then translated into JavaScript
libheif.wasm (as a string)1The only library name that survives minification
ImageDecoder / VideoDecoder (WebCodecs)0It does not ask the operating system to decode anything
new Worker1 site-wideDecoding happens off the main thread — measured in the browser tests

Two practical consequences. Because there is no version string left in the shipped file, I cannot tell you which release of the underlying library this is — minification removed every one, so I am not going to print a number I did not read. And because the worker is created from a blob: URL, the site’s content security policy has to allow it: the response headers include worker-src 'self' blob: and script-src with 'unsafe-eval'. Those are not accidental settings; they are what lets this decoder run.

Why pay it at all

2,999,737 bytes is the price of the alternative. Every converter that does not ship a decoder has to send your photo to a server that has one. This page ships the decoder to you instead, once, and from then on the conversion is local work — which is why it keeps running with the network off, and why the file never leaves your device. If you are on metered data, the sting is the first visit only: 679,355 bytes, and 0 bytes the next time.

Questions

Do I download 2.9 MiB every time I open the page?

No. Measured: the second visit transferred 0 bytes for the decoder and was ready in 307 ms. The uncompressed size is what your browser parses, not what you download twice.

Does it need WebAssembly?

No. Zero references to the WebAssembly API in the file. It is an Emscripten build with isWasm2js set, meaning the original WebAssembly output was translated into JavaScript before shipping.

Why does the page need a worker and blob: permissions?

Because decoding a 12 MP photo on the main thread would freeze the page. The decoder creates one worker from a blob: URL, and the site’s CSP explicitly allows worker-src 'self' blob:. Disabling the Worker constructor kills the conversion in 117 ms — measured in the browser tests.

Does the decoder send anything anywhere?

Nothing in the conversion path. In the offline test I cut the network after load and the conversion still worked, with no requests leaving the browser afterwards.

Can I host the decoder myself?

Not something I tried today. What I can say is measured: it is one self-hosted JavaScript file on the same origin, fetched over ordinary HTTPS with an ETag and a four-hour max-age.

What I could not measure

Curious what your browser fetched? Open the converter, then look in DevTools › Network — the biggest line is the decoder, and it is downloaded once.

First published: 27 September 2026