Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

I Debugged My Browser RAW Preview by Moving One Boundary

I Debugged My Browser RAW Preview by Moving One Boundary I was trying to build a browser RAW preview that felt like a normal file picker: choose a camera file, keep scrolling and clicking, then show a useful preview wh

I Debugged My Browser RAW Preview by Moving One Boundary

I Debugged My Browser RAW Preview by Moving One Boundary

I was trying to build a browser RAW preview that felt like a normal file picker: choose a camera file, keep scrolling and clicking, then show a useful preview when it was ready. My first version did the expensive work on the main thread. It could decode a file, but the interface became the loading indicator.

The fix was not a clever rendering trick. I gave the worker ownership of the file bytes, kept the main thread responsible only for input and presentation, and returned a small, explicit result. For a browser decoder such as FlashRaw, that boundary is a solid starting point: it can extract embedded previews or produce display-ready output locally, but a successful decode still does not prove that every camera mode or display result is right for your application.

Browser main thread and isolated RAW decoding worker

The boundary I stopped crossing

A RAW file is not one small job. Parsing it can involve container inspection, compressed sensor-data decode, metadata interpretation, and optionally a development path toward RGB. Sending every intermediate object back to the page made cancellation, memory ownership, and UI state murky.

So I made one rule: the main thread sends input and receives a preview contract; the worker owns decode details.

Main thread owns Worker owns
file selection, request ID, cancellation intent loading the decoder and inspecting bytes
a loading/error/ready UI state extracting a preview or developing a display image
drawing the returned Blob or ImageBitmap format-specific errors and decoder metadata

This deliberately does not claim that a worker makes every file fast. It prevents a long decode from monopolising the page's event loop. The exact result still depends on the camera, compression variant, device memory, browser, and the output you request. FlashRaw's format matrix makes that distinction explicit: support is tracked by format and mode, not merely by extension.

Transfer the bytes; don't quietly copy them

Worker.postMessage() uses structured cloning, and the browser also supports a transfer list for transferable objects. When an ArrayBuffer is transferred, its ownership moves; the sender cannot keep using that buffer. That is exactly the ownership change I wanted for a file that had entered a decoder.

// preview.js
const worker = new Worker(new URL("./raw-worker.js", import.meta.url), { type: "module" });
let activeRequest = 0;

export async function previewRaw(file) {
  const requestId = ++activeRequest;
  const bytes = await file.arrayBuffer();

  worker.postMessage(
    { type: "decode-preview", requestId, bytes },
    [bytes],
  );
}

worker.addEventListener("message", ({ data }) => {
  if (data.requestId !== activeRequest) return; // stale result

  if (data.type === "ready") {
    const url = URL.createObjectURL(data.previewBlob);
    document.querySelector("img").src = url;
  }

  if (data.type === "failed") {
    console.error(data.message);
  }
});

Memory buffer transferring to a worker

Two details mattered more than I expected:

  1. A request ID prevents an older, slower file from replacing the latest selection.
  2. The returned value should be something the view can consume, such as a Blob, rather than an undocumented decoder-shaped object.

The MDN postMessage() reference explains both the structured-clone message and the transfer-list ownership behavior. If your UI needs the original bytes later, keep a deliberate second copy or read the file againβ€”do not assume the transferred buffer remains usable.

Keep the worker protocol boring

My worker protocol has only three result shapes: progress, ready, and failed. A production decoder wrapper may have richer state, but the page does not need it.

// raw-worker.js
self.addEventListener("message", async ({ data }) => {
  if (data.type !== "decode-preview") return;

  try {
    // Pseudocode: initialise your chosen WASM decoder inside this worker.
    // Ask for an embedded preview first when that satisfies the product need.
    const preview = await decoder.extractPreview(data.bytes);

    self.postMessage({
      type: "ready",
      requestId: data.requestId,
      previewBlob: new Blob([preview.bytes], { type: preview.mimeType },
    });
  } catch (error) {
    self.postMessage({
      type: "failed",
      requestId: data.requestId,
      message: error instanceof Error ? error.message : "RAW preview failed",
    });
  }
});

The code is intentionally pseudocode around decoder.extractPreview: package APIs change, and a preview is not interchangeable with a full demosaiced, colour-managed render. Start by defining the product question: Is a quick embedded preview enough for triage? If not, return a developed image and make the heavier wait visible.

For a companion test plan, I would pair this architecture with a small camera-format fixture gate, not just an extension check. A .NEF or .DNG label is not enough evidence that the precise camera and compression route you ship will work.

I treated threaded WASM as a deployment feature

A basic dedicated worker does not require threaded WebAssembly. That distinction saved me from turning an ordinary UI-responsiveness fix into a header incident. Use a plain worker first; add threaded WASM only when you have profiled a real workload and your decoder/runtime requires it.

When a deployment does need cross-origin isolation, test it like a release feature. Cross-Origin-Opener-Policy: same-origin participates in isolating the browsing-context group, while Cross-Origin-Embedder-Policy changes which cross-origin resources can load. With require-corp, resources fetched without CORS need permission through Cross-Origin Resource Policy; CORS-mode requests still need to be allowed by CORS. Those are integration constraints, not a copy-paste header recipe. See MDN's COOP and COEP references before flipping them on a shared app.

Deployment gate for threaded browser image decoding

My release card is five checks:

  • select a known-good file and an intentionally unsupported fixture;
  • choose a second file before the first result returns;
  • watch the UI stay interactive during the slow case;
  • test the preview result on the target browser and device class;
  • if enabling threads, test every third-party asset and auth popup with the final headers.

What I would measure next

I would not publish a decoder speed number from this setup without a corpus, hardware details, mode, and iteration policy. FlashRaw does publish reproducible comparisons, but those results are scoped to its named machines, files, thread counts, and decode/output modes. My application decision would measure time-to-first-usable-preview, cancellation behavior, peak memory, and correctness for the camera files I actually accept.

If you are moving RAW work into the browser, start with the smallest useful result: an embedded preview in a dedicated worker. Then use the FlashRaw repository and its format documentation to test your own camera matrix before committing to a full render path.

TL;DR: move decode ownership behind a worker boundary, transfer the file buffer intentionally, return a small preview contract, and make threaded WASM a separately tested deployment decision. What would you put in your ready message: a Blob, ImageBitmap, or something else?

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.