Challenges with WebAssembly
The same Rust code runs in a browser tab. It compiled quickly. Getting the colours right, reading files reliably and shipping it without breaking anything took me much longer.
A 50% gray (128) reached the screen as 55, approximately 21%. Every midtone was too dark.
How the browser version is laid out
The browser version is two programs. The main one runs the interface and the graphics on the main thread. A smaller one runs in Web Workers, background threads with no window, and decodes RAW files and renders exports.
A tool called trunk builds it. One setting that the graphics code needs belongs in the trunk config file, but in the trunk version I use it never reaches the compiler, so every build script sets it as an environment variable instead.
It compiled, but it did not run
The compile showed the expected gaps: no system trash, no clipboard, no source of random numbers until you tell a library to use the one in the browser, and starting a thread fails. These show up at compile time or right at startup, and were easy to fix. Then I opened the app in a browser, and it crashed. The compiler could not catch any of these:
- Asking for the time crashed the app. The standard
Rust
Instant::now()is not implemented in the browser, and it crashes instead of returning an error. Now every timer, and a patched copy of the RAW decoder, uses a small library that asks the clock of the browser. - I could not wait on the main thread. The desktop app waits for the graphics card to be ready before it shows a window. A browser page must give control back to the browser, or nothing happens. Now graphics setup starts in the background, and the app checks on every frame whether it is done.
- The window started at 0×0 pixels until the browser reported its size, and WebGPU rejects every drawing command outside the canvas. On a high-resolution screen, my clip rectangles were also twice too big.
- WebGPU is stricter than Metal. One shader stopped early for some pixels and then read from a texture. Metal and Vulkan allowed that. WebGPU refused to compile it.
The colours were wrong
On the desktop, the app gives the graphics card linear light, and the system converts it to the sRGB values that a monitor expects. A WebGPU canvas never offers that conversion, so the linear values went straight to the screen and every midtone was far too dark, as the demo at the top shows. Now the shaders do the conversion themselves on every platform, including the desktop, so the two builds cannot drift apart in how colour reaches the screen. Decoding still differs: Apple ImageIO on macOS, the app's own decoders everywhere else.
Threads without shared memory
Before the port, I wrote a memo on whether it was feasible. It got
memory right and threading completely wrong, and I know that only
because I wrote it down. For threading, it predicted the usual
approach: memory shared between workers, which needs the site to send
two special security headers, plus a library that lets the Rust thread
pool run in the browser. That library expects JavaScript to set up the
thread pool before the Rust program starts, and LightPhotos is a
normal Rust program with its own main().
So I dropped shared memory. Each worker runs its own copy of the worker program. The main thread sends it the bytes of a file and gets pixels back. No special headers, no experimental Rust. The cost is memory, because each worker has its own, so the pool stops at four workers no matter how many cores you have. A browser tab can address only a limited amount of memory, because it is 32-bit, and that memory never shrinks after it grows. See measuring a GUI app and never load the whole folder.
No recovery after a crash
In the browser, a crash in any library is my problem. On the desktop, if the RAW decoder panics on an unusual file, the app catches the panic and shows a placeholder. WebAssembly in the browser has no way to recover from a panic, so the panic kills the worker outright. So the app checks each file before the decoder gets it:
- It rejects file layouts that the decoder has not implemented yet, where it would hit a
todo!(). - It rejects files from Fujifilm X-Trans sensors, which hit an
unreachable!()in the decoder. - It checks width times height for overflow, which a big RAW file can cause in the 32-bit build but never on a 64-bit Mac.
When it can, the worker skips the decoder entirely and uses the JPEG preview that most cameras embed in the RAW file. That is faster, and it cannot crash.
Files, one handle at a time
The File System Access API lets the page read and write inside a folder that you pick. It gives only handles, never paths, and every operation is asynchronous. Two errors took me the longest:
-
NotReadableError. Chrome throws it when too many file reads run at once. Two at a time never caused it. The app allows four, and when one fails it retries after a random delay that grows each time. Retries right away or at fixed intervals failed again, because all the reads retried at the same moment. -
RangeError: Array buffer allocation failed. The app copied each RAW file into JavaScript and again into WebAssembly memory, and after a few reads in a row the tab ran out of memory. Now the bytes move across without the extra copy.
You must still pick the folder again each time you open the page. Deletes are permanent, because the API has no trash. The app warns you first.
Deploying broke it
The worst bug was not in the Rust code. How I deploy matters as much as the code. Cloudflare cached the worker JavaScript for four hours and its WebAssembly file for zero. After a release, recent visitors got old JavaScript paired with new WebAssembly, and every decode failed with an error that said a function did not exist.
1 h: old worker JS with new wasm. Every decode fails.
The fix adds the build hash of the main bundle, a fingerprint of the
build, to the worker file
addresses, so every release gets new addresses. When I moved the app
under /app/, the worker address broke too. Now the worker
works out its own location from the page instead of assuming it.
What the browser version gives up
- It works only in desktop Chrome and Edge, which have WebGPU and folder access.
- No HEIC and no face detection.
- No Immich upload, because Immich servers do not accept requests from web pages.
- No clipboard. Exports always go to an
Exportsfolder inside the folder that you picked. - You download approximately 4.3 MB, compressed, before the app starts.
Was it worth it?
Yes. A link is easier to send than an installer. The port also made the desktop app better: shared shader colour conversion, thread pools that do not panic when they cannot start, and a thumbnail cache next to your photos. If the web app breaks for you, tell me your browser and what you did on the issue tracker.