LightPhotos

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.

Colour path:
Shader output linear light No conversion the WebGPU canvas has none Monitor expects sRGB values GRAY TEST BARS: VALUE ON SCREEN, 0 TO 255 target 64 13 target 128 55 target 192 134 outline: the correct value

A 50% gray (128) reached the screen as 55, approximately 21%. Every midtone was too dark.

Switch between the first browser build and the current one. The compiler caught none of this.

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.

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.

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:

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:

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:

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.

WHAT A VISITOR GETS FROM THE CACHE Worker JS cached 4 hours old build new build Worker wasm cached 0 hours new build 0 h 1 h 2 h 3 h 4 h 5 h 6 h

1 h: old worker JS with new wasm. Every decode fails.

Move the slider, then add the build hash. With new addresses, the cache has no old copy to give.

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

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.