Challenges with WebAssembly
The same Rust code runs in a browser tab. Getting it to compile was quick. Getting the colours right, reading files reliably and shipping it without breaking took me much longer.
What I learned
- Getting it to compile didn't tell me much. The first time the app ran in a browser it hit three bugs, and the compiler couldn't have caught any of them.
- Writing my predictions down helped. My plan got memory right and threading completely wrong. I only know that because I wrote the plan down before I started.
- In the browser, a crash in any library is my problem. When Rust code panics (crashes on purpose because something unexpected happened) in the browser, it kills the worker. So I have to check a file before handing it to a decoder that might panic on it.
- How I deploy matters as much as the code. Two files from the same build were cached for different lengths of time, and that broke every photo for anyone who had visited recently.
How the browser version is laid out
The browser version is really two programs. The main one runs the window, the interface and the graphics on the page's main thread. A second, smaller one runs in Web Workers, which are background threads the browser gives you, and it does the slow work of decoding RAW files and rendering exports. I needed the second program because a worker has no window, so it can't run the interface or the graphics code.
I build everything with a tool called trunk, which runs the Rust compiler and generates the JavaScript that connects the program to the page. Even that had a catch. One setting the graphics code needs is supposed to go in trunk's config file, but in the version I use it never reaches the compiler. So every build script sets it as an environment variable instead.
It compiled, but it didn't run
Getting the code to compile for the browser turned up the problems I expected. There's no system trash, no clipboard, no source of random numbers until you tell a library to use the browser's, and starting a thread fails. Those show up when you compile or right at startup, and they were easy to fix.
Then I opened it in a browser, and it crashed.
- Asking for the time crashed the app. Rust's
standard
Instant::now()isn't implemented in the browser, and instead of returning an error, it crashes. The app checks the clock at startup, so every launch died. Every timer in the app now uses a small library that asks the browser's clock instead, and so does a patched copy of the RAW decoder. - I couldn't wait on the main thread. The desktop app waits for the graphics card to be ready before showing a window. A browser page has to hand control back to the browser, or nothing happens. Graphics setup now starts in the background, and the app checks every frame whether it's done.
- The window started at zero by zero. Until the browser reports the canvas size, it's 0×0 pixels, and WebGPU (the browser's way of using the graphics card) rejects every drawing command outside the canvas. On a high-resolution screen, the rectangles I use to clip drawing were also twice too big.
- WebGPU is stricter than Metal. One shader (a small program that runs on the graphics card) stopped early for some pixels and then read from a texture. Metal, Apple's graphics system, and Vulkan had quietly allowed that. WebGPU refused to compile it.
The colours were wrong
Once it drew anything, everything looked muddy. A 50% gray showed up as about 21%.
On the desktop, the app gives the graphics card colours in linear light, meaning numbers that scale with the actual amount of light. The system then converts them to sRGB, the values a monitor expects. A WebGPU canvas never offers that conversion, so the linear values went straight to the screen and every midtone came out far too dark. Gray test bars that should have read 64, 128 and 192 read 13, 55 and 134.
I fixed it by doing the conversion in the shaders myself, on every platform, including the desktop. Because that last step is now shared, the desktop and browser builds can't drift apart on how colour reaches the screen. Decoding still differs. On macOS, photos come in through Apple's ImageIO, and everywhere else through the app's own decoders.
Threads without shared memory
Before I started the port, I wrote a memo on whether it was even feasible. 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 Rust's thread pool run in the browser.
That library didn't fit. It 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
altogether. Each worker runs its own separate copy of the worker
program. The main thread sends it a file's bytes and gets pixels
back.
This ended up simpler than what I'd planned. It doesn't need special headers from the server or experimental Rust. The cost is memory, because each worker has its own. That's why the pool stops at four workers, no matter how many cores you have.
The memo's warnings about memory were right, though. A browser tab can only address a limited amount of memory (it's 32-bit), and memory it grows never shrinks back. I've written about that in measuring a GUI app and never load the whole folder.
No second chances after a crash
On the desktop, if the RAW decoder panics on a strange file, the app catches it and shows a placeholder. The browser build can't do that. WebAssembly in the browser has no way to recover from a panic, so it kills the worker outright.
So the app checks each file before handing it to the decoder:
- File layouts the decoder hasn't implemented yet, where it would hit a
todo!(), are turned away. - Files from Fujifilm's X-Trans sensors, which hit an
unreachable!()in the decoder, are turned away. - Image sizes are checked for overflow. In the 32-bit browser build, a big RAW file's width times height can overflow where it never would on a 64-bit Mac.
When it can, the worker skips the decoder entirely and uses the JPEG preview most cameras embed in the RAW file. That's faster, and it can't crash.
Files, one handle at a time
The File System Access API is what makes the web version possible at all. You pick a folder, and the page can read and write inside it. But the page never sees a path, only handles, and every operation is asynchronous, meaning you ask and the answer arrives later. Code that used to say "list the directory" now says "ask for the list, and use it when it arrives".
Two errors took me the longest to understand.
-
NotReadableError. Chrome throws it when too many file reads run at once. Two at a time never triggered it. The app allows four, and when one fails it retries after a random delay that grows each time. When I retried right away, or at fixed intervals, it failed again, because all the reads retried at the same moment. -
RangeError: Array buffer allocation failed. Reading a RAW file copied it once into JavaScript and again into WebAssembly's memory. A few of those in a row and the tab ran out of room. Now the bytes move across without the extra copy.
There are rough edges I haven't fixed yet. You have to pick the folder again every time you open the page. Deleting a photo is permanent, because the API has no trash, and the app warns you about that before it deletes.
Deploying broke it
The worst bug in the whole port wasn't in the Rust code. The site is served through Cloudflare, which cached the worker's JavaScript for four hours and its WebAssembly file for zero. After a release, anyone who had visited recently got old JavaScript paired with new WebAssembly, and every decode failed with an error saying a function didn't exist.
To fix it, the worker files are now requested with the main bundle's
build hash (a fingerprint of that build) added to the end of the
address, so every release gets new addresses for both. Moving the app
under /app/ broke the worker's address too, so now it
works out where it lives from the page instead of assuming.
What the browser version gives up
- Only desktop Chrome and Edge, which have WebGPU and folder access.
- No HEIC, no face detection, no subject selection.
- No Immich upload, because Immich servers don't accept requests from web pages.
- No clipboard, no Lightroom preset import, and you can't choose where exports go. They go to an
Exportsfolder inside the one you picked. - You download about 4.3 MB, compressed, before the app starts.
Was it worth it?
Yes, I think so. Sending someone a link is a lot easier than asking them to install something, and the port made the desktop app better along the way. Every platform now uses the same shader colour conversion, thread pools no longer panic when they can't start, and the thumbnail cache lives next to your photos.
If you try the web app and it breaks, I'd really like to know which browser you used and what you were doing. The issue tracker is the best place to tell me.