LightPhotos

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

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.

ONE BROWSER TAB Main thread interface, catalog, WebGPU drawing 12 MB wasm, 3.2 MB compressed jobs Up to 4 Web Workers worker worker worker worker each its own copy, its own memory RAW decode, export rendering Your folder File System Access API, no paths No shared memory, no special server headers. Workers get bytes in and send pixels back.

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.

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:

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.

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

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.