The Problem: Server Cost Tradeoffs vs. Thread-Blocking JS

In the domain of scientific and optical engineering applications, numerical modeling presents a particularly vexing set of choices:

  • The Server-Side Compute Tradeoff: Hosting compute-intensive numerical simulations (wave propagation, transfer matrices, spectrum analysis, etc.) on the server introduces compute and scaling costs. As usage grows, those costs and network round trips can make interactive parameter exploration harder to sustain in a browser-based UI.
  • The Naive JS Approach: Allocation-heavy calculations in browser JavaScript can create avoidable overhead, especially when complex-number objects and large temporary arrays are created inside hot loops. The size of that overhead is workload- and engine-dependent, so it should be measured rather than summarized with a universal multiplier. Running the calculation on the main thread can also hurt interaction, which is why the work is isolated in a Web Worker.

When building BraggLive, a real-time parameter exploration tool for optical wave propagation modeling and spectrum simulation, neither of these options were appealing — both approaches made it harder to meet the requirements of running intensive physics calculations in a browser without per-request server compute and with a responsive UI during interactive parameter changes.

The only viable architecture turned out to be a combination of precompiled WebAssembly (Wasm) and web workers with typed-array views over Wasm memory.


TL;DR: Comparison Table

The table below highlights the key differences between the naive approaches to Web-based modeling and the production-ready BraggLive architecture.

Dimension Naive JavaScript approach / Server-side API Production-ready BraggLive WebAssembly + Web Workers Tradeoffs/Impact
Location of Compute Heavy API calls to cloud server hosting numerical simulation WebAssembly module compiled to Wasm (AssemblyScript) A small module download and client-side execution can remove per-request server compute, but startup cost and runtime speed should be measured on target devices
Memory Allocation JS Object Allocation per Grid Point (new Complex(re, im)) Preallocated Float64Array Buffers in Wasm Heap Reduces JS-side temporary allocations and can reduce GC pressure in numeric loops; it does not eliminate garbage collection across the application
Main Thread Latency Freezing UI during multi-second numerical model execution Non-blocking execution inside dedicated Web Worker Execution is isolated from the main thread and progress is communicated via postMessage(); responsiveness still depends on message volume, rendering work, and the target device
Data Interop Overhead JSON.stringify() and deserialization between JS and Wasm Float64Array Views over Wasm Linear Memory Can avoid a copy for the mapped buffer path, but slices, transfers, and memory growth can still allocate or copy data
Visualization UX Recreating entire Plotly visualization on every state change Selective Plotly.js uirevision updates with debounced progress reporting Tradeoff: Manual state tracking of which visualization elements changed

Core Architectural Patterns

Pattern 1: High-performance Complex Math in WebAssembly

Modeling optical wave propagation involves evaluating transfer matrices at regular intervals along the propagation axis. At each sample point, a complex 2x2 matrix needs to be evaluated:

Mj = cosh(γΔz) - i(σ/γ)sinh(γΔz) -i(κ/γ)sinh(γΔz) i(κ/γ)sinh(γΔz) cosh(γΔz) + i(σ/γ)sinh(γΔz)

The naive approach of performing these calculations in JS would result in millions of short-lived object allocations per second inside matrix multiplication loops, killing performance due to memory fragmentation and GC overhead.

By implementing core math functions in WebAssembly (via AssemblyScript bindings), complex arithmetic operations can be implemented with minimal overhead:

// assembly/index.ts - Inline Complex Number primitives, compiled to WebAssembly
class Complex {
    re: f64;
    im: f64;

    @inline constructor(re: f64, im: f64 = 0) {
        this.re = re;
        this.im = im;
    }

    @inline static mul(a: Complex, b: Complex): Complex {
        return new Complex(
            a.re * b.re - a.im * b.im,
            a.re * b.im + a.im * b.re
        );
    }

    @inline static exp(c: Complex): Complex {
        const r = Math.exp(c.re);
        return new Complex(r * Math.cos(c.im), r * Math.sin(c.im));
    }
}

Design Principle 1: Keep Allocations Out of Numeric Hot Loops.
In compute-heavy browser applications, avoid unnecessary object allocation inside critical loops. Compiling a numeric kernel to WebAssembly and using preallocated buffers can reduce JavaScript-side allocation and GC pressure, but the complete application still has normal runtime memory management.

Pattern 2: Typed-Array Views over Linear Memory (__getFloat64ArrayView)

When performing intensive calculations in Wasm, it's common to need to pass arrays of float data (domain values, amplitude responses, phase shifts, etc.) between JS and Wasm. The naive approach of using Array.from() or JSON.stringify() to copy data between contexts results in substantial memory overhead.

The alternative is to map Float64Array views directly over the Wasm linear memory:

+-----------------------------------------------------------------------------------+
|                    TYPED-ARRAY VIEW WASM MEMORY ARCHITECTURE                      |
|                                                                                   |
|  +-----------------------------------------------------------------------------+  |
|  | WebAssembly Linear Memory (WebAssembly.Memory Heap Buffer)                  |  |
|  |                                                                             |  |
|  |  [0x0000] Params Array (Length, Frequency, Bandwidth, Profile...)           |  |
|  |  [0x0400] Domain Vector Pointer --------------------+                       |  |
|  |  [0x1000] Response Matrix Pointer ------------------  |                     |  |
|  |  [0x1C00] Phase Shift Pointer ----------------------|--+                    |  |
|  +---------------------------------------------|--|--|-------------------------+  |
|                                                |  |  |                            |
|                        Direct Pointer Access   |  |  |                            |
|                   (Mapped Typed-Array Views)   v  v  v                           |
|  +-----------------------------------------------------------------------------+  |
|  | Web Worker JavaScript Layer                                                 |  |
|  |                                                                             |  |
|  |  const wasmParams = wasmExports.__getFloat64ArrayView(wasmParamsPtr);       |  |
|  |  const simX       = wasmExports.__getFloat64ArrayView(xPtr);                |  |
|  |  const simY       = wasmExports.__getFloat64ArrayView(yPtr);                |  |
|  +-----------------------------------------------------------------------------+  |
+-----------------------------------------------------------------------------------+

Inside the Web Worker thread, parameters can be written directly to the linear memory:

// Worker Thread - Direct Memory View mapping over Wasm heap
const wasmParamsPtr = getPtr(wasmExports.params);
// Get a typed-array view over Wasm linear memory; refresh it after memory growth.
const wasmParams = wasmExports.__getFloat64ArrayView(wasmParamsPtr);

// Write simulation inputs directly to Wasm heap
wasmParams[0] = inputs.sampleLength;
wasmParams[1] = inputs.centerFrequency;
wasmParams[2] = inputs.bandwidth;
wasmParams[3] = inputs.dampingFactor;
wasmParams[4] = inputs.windowSpan;
wasmParams[5] = inputs.couplingCoefficient;

// Trigger calculation inside Wasm binary
wasmExports.computeProfile();
wasmExports.simulate(currentIdx, nextIdx);

// Direct pointer dereference for output arrays
const xPtr = getPtr(wasmExports.spectrumDomain);
const yPtr = getPtr(wasmExports.amplitudeResponse);
const simX = wasmExports.__getFloat64ArrayView(xPtr);
const simY = wasmExports.__getFloat64ArrayView(yPtr);

Design Principle 2: Avoid Unnecessary Serialization Between JS and Wasm
Use direct pointer access to Float64Array views over Wasm linear memory where the runtime and memory lifetime allow it. Slices, transfers, memory growth, and API boundaries may still require copies.

Pattern 3: Chunked Worker Execution & Progress Reporting

When performing intensive calculations (e.g. fine-resolution pulse propagation simulation), it's often necessary to split the work into smaller chunks to keep the Web Worker responsive to abort requests and progress reporting messages.

By dividing the domain's value range into N equal parts and processing them sequentially in the Web Worker, the UI can stay updated with the calculation progress:

// Worker Thread - Chunked processing loop with progress reporting
const chunkSize = Math.max(1, Math.ceil(totalPoints / 20)); // Report progress 20 times
let currentIdx = startIndex;

while (currentIdx < endIndex) {
    const nextIdx = Math.min(currentIdx + chunkSize, endIndex);

    // Compute range [currentIdx, nextIdx) inside WebAssembly
    wasmExports.simulate(currentIdx, nextIdx);

    // Extract sliced memory block
    const subX = wasmExports.__getFloat64ArrayView(xPtr).slice(currentIdx, nextIdx);
    const subY = wasmExports.__getFloat64ArrayView(yPtr).slice(currentIdx, nextIdx);

    combinedX.set(subX, offset);
    combinedY.set(subY, offset);
    offset += subX.length;

    currentIdx = nextIdx;

    // Report incremental progress back to the main UI thread
    const progress = Math.min(100, Math.round((offset / totalPoints) * 100));
    self.postMessage({ type: 'progress', progress: progress });
}

Design Principle 3: Always Chunk Heavy Calculations to Keep Web Worker Responsive
When using Web Workers, it's essential to keep the event loop running even during heavy calculations. This allows the worker to handle abort signals and UI progress reporting messages.

The Big Picture

Here's the general overview of the system architecture that implements these patterns:

+-----------------------------------------------------------------------------------+
|                           BROWSER MAIN THREAD (UI)                                |
|  +---------------------+   +-----------------------+   +-----------------------+  |
|  | Interactive Inputs  |   | Debounce & Queue Ctrl |   | Plotly.js Dashboard   |  |
|  | (Mouse Wheel/Form)  |   | (Prevents UI Lag)     |   | (uirevision Persist)  |  |
|  +----------+----------+   +-----------+-----------+   +-----------^-----------+  |
+-------------|--------------------------|---------------------------|--------------+
              | Parameter Payload        | Worker PostMessage        | Render Frame
              v                          v                           |
+--------------------------------------------------------------------|--------------+
|                        BACKGROUND WEB WORKER THREAD                |              |
|  +-----------------------------------------------------------------+-----------+  |
|  | Worker Event Loop (worker.js)                                               |  |
|  | Receives parameter events, manages chunked simulation loop & progress       |  |
|  +-------------------------------------+---------------------------------------+  |
+----------------------------------------|------------------------------------------+
                                         | Direct Memory Mapping & Exports
                                         v
+-----------------------------------------------------------------------------------+
|                        COMPILED WEBASSEMBLY ENGINE (model.wasm)                   |
|  +-----------------------------------------------------------------------------+  |
|  | Linear Memory (Float64Array Heap Buffers)                                   |  |
|  |                                                                             |  |
|  | +-----------------------+   +-------------------+   +---------------------+ |  |
|  | | Profile Generator     |   | Transfer Matrix   |   | Fast Fourier Engine | |  |
|  | | (Window Functions)    |   | (Complex Numerics)|   | (Dispersion Model)  | |  |
|  | +-----------------------+   +-------------------+   +---------------------+ |  |
|  +-----------------------------------------------------------------------------+  |
+-----------------------------------------------------------------------------------+

Pattern 4: Plotly UI revision & viewport persistence

A common UX anti-pattern in web-based scientific plotting applications is the loss of plot exploration context (panning/zooming) when recalculating plots after parameter changes. The naive approach of recomputing x, y arrays and replacing them with Plotly's newPlot() call will cause the viewport to always be reset to default limits.

By utilizing Plotly's uirevision property, BraggLive can decouple mathematical model updates from viewport state:

// Main Thread Plotly update logic
let plotUiRevision = 0;

function updateSpectrumPlot(xData, yData, autoScale = false) {
    if (autoScale) {
        // Incrementing uirevision forces Plotly to recalculate axes
        plotUiRevision++;
    }

    const plotData = [{
        x: xData,
        y: yData,
        type: 'scatter',
        mode: 'lines',
        line: { color: '#00d2ff', width: 2 }
    }];

    const layout = {
        uirevision: plotUiRevision, // Preserves user zoom/pan when unchanged!
        margin: { t: 10, b: 30, l: 50, r: 20 },
        autosize: true
    };

    Plotly.react('spectrumPlot', plotData, layout);
}

When autoScale is false, the uirevision value (and therefore the viewport scaling) is preserved, allowing Plotly.js to maintain the user's panning/zooming context between consecutive plot updates.

Design Principle 4: Do Not Reset Pan/Zoom Context for Interactive Plots
Use Plotly's uirevision property to avoid unwanted viewport resets when updating mathematical model data.


Decision Guide and Checklist

Consider using this architectural approach when:

  • Heavy Numerical Compute is Needed: You need to perform complex linear algebra, differential equations, FFTs, or matrix operations over large sets (>10,000 points).
  • Per-request Server Compute is Undesirable: You want to move suitable numeric work to the client while accepting hosting, bandwidth, download, and device-performance costs.
  • Interactive UX is Required: You want to reduce network round-trips for repeated parameter changes and can keep the main thread responsive.
  • Deterministic Memory Management is Critical: JS garbage collection pauses are unacceptable for your use case.

Avoid using the architecture when:

  • Your Use-case is Simple: The application doesn't involve heavy computation and can get away with naive approaches.
  • The Compute is Trivial: The calculations are simple enough to execute in JS with no performance penalties.
  • You Need to Protect IP: The mathematical algorithms need to be protected from being inspected by end-users.

BraggLive's Experience with WebAssembly

Web browsers can execute substantial numerical workloads locally. By combining WebAssembly for a suitable numeric kernel, Web Workers for isolation, and typed-array views where appropriate, an application can reduce per-request server compute and preserve a responsive UI. The resulting speed, memory use, download cost, and device coverage still need to be measured against the target workload.