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.
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.
| Measure | Value |
|---|---|
| Uncompressed size | 2,999,737 bytes |
| Compressed body (Brotli) | 679,055 bytes |
| On the wire, headers included | 679,355 bytes |
| Compression ratio | 4.42× |
| Time to fetch it (cold) | 407 ms |
| Whole first page load, measured separately | 688,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.
| First visit (cold profile) | Second visit (cache warm) | |
|---|---|---|
| Time until the converter is ready | 1,939 ms | 307 ms |
| Decoder bytes transferred | 679,355 | 0 |
| Decoder fetch time | 407 ms | 0 ms |
| Decoded size in memory | 2,999,737 bytes | 2,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.
I blocked the request for heic-to.js at the network layer and loaded the page again:
typeof window.HeicTo is undefined.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.
Reading the 2,999,737 bytes rather than guessing:
| Found in the file | Count | What it tells you |
|---|---|---|
WebAssembly API references | 0 | Nothing asks the browser for WASM at runtime |
isWasm2js | 2 | An Emscripten build: libheif was compiled to WebAssembly, then translated into JavaScript |
libheif.wasm (as a string) | 1 | The only library name that survives minification |
ImageDecoder / VideoDecoder (WebCodecs) | 0 | It does not ask the operating system to decode anything |
new Worker | 1 site-wide | Decoding 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.
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.
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.
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.
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.
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.
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.
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