WebAssembly was not a reason to rewrite

A bounded W93H experiment found a real compute win inside one kernel and no justification to move parsing, state, or interface ownership out of JavaScript.

WebAssembly 1.0 became a W3C Recommendation in December 2019. By then, the question around it was no longer whether the browser could run a compact low-level module. The more useful question was which work should cross the boundary.

I had a plausible candidate in W93H. Large incident scenarios could contain tens of thousands of events. The reconstructed timeline applied clock corrections, preserved partial-order constraints, grouped overlapping uncertainty intervals, and built indexes for filtering. One synthetic fixture made the browser pause long enough to feel broken.

Rewriting the timeline engine in a language that compiled to WebAssembly sounded like the modern response. I built a narrow Rust experiment instead and measured the whole path.

One numeric kernel became faster. The page did not become meaningfully faster until I changed when and how the work ran. WebAssembly was a useful tool inside the solution and a poor reason to redesign the application around it.

I started with the delayed interaction

The visible problem occurred when opening an unusually large replay. The page rendered its shell, then became unresponsive while it transformed the event set. Clicking a filter or moving focus during that interval produced no immediate response.

I divided the work into phases with browser performance marks:

  1. Download and decompress the scenario response.
  2. Parse JSON into JavaScript objects.
  3. Validate event envelopes and schema versions.
  4. Build source-local and explicit causal edges.
  5. Project timestamps through clock corrections.
  6. Group overlapping intervals and calculate display rows.
  7. Filter and render the visible portion.

The trace changed the story. JSON parsing and object construction consumed a large share. Validation traversed strings and tagged payloads. The partial-order and interval calculations were compute-heavy but received already normalized numeric data. Rendering too many rows created another separate cost.

“Timeline processing is slow” was not one workload. A rewrite that accelerated phase five while preserving blocking work in phases two, three, and seven could benchmark well in isolation and leave the interaction poor.

I defined success as reduced time until the page accepted useful input, not a higher operations-per-second number inside a new module.

WebAssembly did not replace the Web platform

The module ran inside the browser's WebAssembly execution environment. It did not receive direct ambient access to the DOM, network, files, or W93H state. The JavaScript host instantiated it, supplied imports, moved data through linear memory, and interpreted its output.

That boundary was a feature. The module's authority could remain narrow. It was also work.

W93H events were rich JavaScript objects containing IDs, strings, tagged payloads, timestamps, optional sequence values, and evidence links. The ordering kernel wanted compact numeric arrays. I needed to translate:

event objects
  -> clock-domain and event indexes
  -> numeric lower/upper time bounds
  -> source-sequence and causal-edge arrays
  -> WebAssembly linear memory
  -> group/index output
  -> JavaScript view model

Passing every event through a string-oriented foreign-function interface would spend time encoding, allocating, copying, and decoding. Letting the Rust module parse all JSON would duplicate validation rules and move domain ownership merely to avoid the bridge.

I kept parsing and schema validation in TypeScript. The module received typed numeric buffers for one well-defined calculation.

WebAssembly expanded the set of languages and performance profiles available on the Web. It did not make JavaScript an obsolete delivery mechanism or give low-level code automatic ownership of the interface.

The candidate kernel had a stable contract

The best candidate was interval grouping under partial-order constraints.

For each normalized event, JavaScript supplied a lower and upper display-time bound plus a stable event index. It also supplied directed edges that had to be preserved, such as source sequence or request-before-response. The kernel returned a group number and stable order key for each event, or an error if the causal graph contained a contradiction.

The boundary did not know about React, incident filters, event descriptions, or source payload schemas:

type OrderingInput = {
  lowerMs: Float64Array
  upperMs: Float64Array
  edgeFrom: Uint32Array
  edgeTo: Uint32Array
}
 
type OrderingOutput = {
  group: Uint32Array
  order: Uint32Array
  contradiction?: Uint32Array
}

The typed-array shape was useful even before WebAssembly. It forced me to separate domain normalization from the numeric algorithm and made equivalent JavaScript and Rust implementations possible.

I wrote shared fixtures from W93H's scenario engine. Both implementations had to preserve source-local order, group unresolved overlaps, expose contradictory edges, and produce deterministic tie-breaking. The tests compared outputs rather than internal data structures.

This prevented the experiment from becoming “Rust output looks plausible.” The existing behavior remained the contract.

The first benchmark measured the wrong thing

My first result timed only the exported WebAssembly function after memory had been allocated and input arrays copied. It was substantially faster than the straightforward JavaScript implementation on the largest fixture.

That number excluded:

  • Fetching and compiling the module.
  • Instantiating it and establishing imports.
  • Growing or allocating linear memory.
  • Copying normalized inputs.
  • Copying or viewing the output safely.
  • Translating result indexes into the view model.
  • The parsing and validation work that still preceded the call.

When I timed the entire boundary, the advantage shrank. On ordinary incidents with hundreds of events, WebAssembly was slower because setup and copying outweighed the kernel. On large fixtures, it remained faster inside the calculation but was not the dominant phase of opening the page.

I kept three benchmark scopes: kernel-only, boundary-inclusive, and user-visible task. Each answered a different question. The README led with the third.

The exercise echoed the nine-minute outage. A precise number can be true inside its definition and misleading when allowed to summarize a larger process.

If W93H loaded the module eagerly with the application, every visit paid its download, compilation, and instantiation cost even when the incident contained twenty events. If it loaded lazily, the first large replay paid a cold-start delay at exactly the moment the user had already requested expensive work.

Modern browsers could compile WebAssembly efficiently, and the binary format was compact, but “efficient” did not mean free. Caching behavior, device speed, and network conditions still mattered.

I measured:

  • A cold page with no cached module.
  • A repeat visit with the module cached.
  • A warm worker retaining an instantiated module.
  • A small ordinary incident.
  • A medium replay.
  • The intentionally large stress fixture.

The warm worker was the best WebAssembly case. The small incident was the worst. The threshold at which the module won varied by device enough that one fixed event count would be brittle.

Rather than ship a complex adaptive selector immediately, I treated the experiment as evidence about where the application spent time. The actual product change would target responsiveness across all sizes.

Moving work off the main thread mattered first

The page froze because parsing and transformation occupied the main thread. A JavaScript implementation in a Web Worker could preserve interaction even if the total calculation took the same time.

I moved scenario parsing, validation, normalization, and ordering into a worker. The UI rendered a stable shell and progress states based on meaningful phases: downloading, validating, reconstructing, and ready. Filters that could operate on the existing index remained responsive.

Structured cloning large object graphs had a cost. The worker therefore retained the normalized event set and returned compact indexes and visible slices. Transferable ArrayBuffer values moved numeric data without unnecessary copies where ownership could safely transfer.

The worker version produced the largest perceived improvement. Total processing time fell modestly after removing repeated transformations, but the main win was that focus, cancellation, and navigation still worked.

Only after that boundary existed did the WebAssembly kernel become easy to place inside the worker. Its synchronous calculation no longer blocked the interface thread. The JavaScript fallback implemented the same worker protocol.

Concurrency architecture solved the interaction problem. WebAssembly optimized one compute stage within that architecture.

WebAssembly exposed linear memory through an ArrayBuffer. Rust code could allocate and later grow that memory. A JavaScript typed-array view created before growth could become detached or point at an obsolete buffer.

My first wrapper cached views for reuse. A larger fixture caused memory growth, and the wrapper read from the old view. The result looked like corrupt group indexes.

I changed the wrapper so every exported operation returned offsets and lengths, then refreshed JavaScript views from the module's current memory buffer before reading. Ownership rules were written beside the boundary:

  • JavaScript owns input arrays until they are copied into module memory.
  • The module owns its internal allocation for the duration of the call.
  • JavaScript copies the small output or consumes a view before the next mutating module call.
  • No React state stores a live view into mutable module memory.

I considered a shared allocator and zero-copy protocol. For the measured data sizes, its complexity exceeded the copy cost. A simple copy made lifetime obvious and kept the JavaScript fallback equivalent.

Low-level memory control can create performance opportunities. It also reintroduces ownership problems that garbage-collected application code had kept at a distance.

Strings stayed outside the kernel

Incident events were full of strings: source IDs, kinds, descriptions, correlation keys, and annotation text. Moving them into the module would require encoding and an allocation protocol.

The ordering algorithm did not need them. JavaScript mapped each event to a numeric index and retained the rich record. The module returned indexes. This reduced bridge cost and kept international text, URL handling, and interface semantics in the environment already responsible for them.

The same choice limited the rewrite. It was tempting to move filtering into Rust after the ordering kernel showed a speedup. Text filtering would cross different costs and rely on language-sensitive behavior. I measured it separately and kept it in JavaScript.

This was an important architectural habit: adjacency is not a performance argument. Two phases can sit beside each other in a pipeline and still belong on different sides of a boundary.

The WebAssembly module remained almost boring. That was a sign the contract was appropriately narrow.

The first exported function returned 0 for success and a nonzero value for failure. The wrapper converted every failure into “timeline processing failed.”

W93H needed to distinguish invalid input, contradictory causal edges, allocation failure, unsupported module version, and an internal defect. Some states should fall back to JavaScript; others indicated the event set itself required investigation.

I defined a compact result structure with an error kind and relevant event indexes. The TypeScript wrapper converted it into a tagged domain result. Rust panic behavior was configured and tested so an unexpected failure did not leave the worker reporting indefinite progress.

The worker caught module instantiation and execution failures, recorded the module and wrapper versions, and attempted the JavaScript path when the input remained valid. The interface said that optimized reconstruction was unavailable rather than clearing the timeline.

Fallback was not an excuse to ignore the module failure. W93H emitted a diagnostic event with no incident payload data and kept a bounded count. A silent fallback would let the optimized path decay unnoticed.

The module was optional for performance and accountable for correctness.

The sandbox was a boundary, not a security review

WebAssembly code executed inside the browser's sandboxed environment and had no ambient access to host capabilities. Imports supplied by JavaScript defined what it could call. That was a strong baseline.

It did not make any module safe by origin alone. A module could consume CPU, grow memory within limits, exploit a bug in its own unsafe code, return malicious indexes, or call powerful imported functions if I provided them.

The W93H module received only memory and narrow numeric functions. It did not receive network, DOM, storage, or credential access. Output indexes were bounds-checked before they selected JavaScript events. Resource limits constrained fixture size and worker duration.

The build path pinned its Rust dependencies, recorded the compiler and tool versions, and produced a digest for the .wasm artifact. The browser loaded the module from the same trusted release and used a restrictive content policy consistent with the application.

I did not describe this as running untrusted code safely. It was my own compiled module under a narrow host interface. The sandbox reduced authority; supply chain and logic correctness still needed ordinary discipline.

Debugging crossed two toolchains

The TypeScript implementation could be inspected with familiar browser tools. The Rust module added compiler configuration, generated bindings, source maps with uneven debugging quality, and a second set of stack traces.

A wrong order could originate in normalization, memory layout, the Rust algorithm, wrapper offsets, or view-model translation. I added a debug mode that retained the normalized numeric input and could run both implementations, then compare their results at the first divergence.

Small deterministic fixtures remained more valuable than stepping through the largest scenario. Each fixture named one property: nested intervals, equal bounds, source-order contradiction, clock-step overlap, or duplicate edge.

The module build was reproducible through a repository script. Generated bindings and the .wasm digest entered the release manifest. A version handshake prevented a new wrapper from invoking an older cached module with an incompatible memory layout.

Performance code increases the number of places correctness can hide. The debugging and version story belonged in the cost calculation, not in a later cleanup phase.

After worker isolation and the optimized kernel, the stress fixture could be reconstructed without freezing the page. Rendering every row still produced too much DOM and layout work.

The interface did not need every event mounted simultaneously. It needed a truthful count, stable navigation, searchable indexes, and a visible window around the current position. I introduced windowed rendering cautiously, preserving keyboard navigation, focused-item retention, find behavior inside W93H's own search, and accessible announcements of position.

The change made much larger datasets usable than the kernel optimization alone. It also required more product judgment. A visually absent row could not vanish from the semantic model or break a link to a specific event.

This reinforced the phase analysis. Network, parsing, transformation, scheduling, and rendering were separate levers. WebAssembly addressed one of them.

If I had started with a rewrite, the effort could have delayed the worker and rendering changes that affected the actual interaction most.

The benchmark result was conditional

On the largest synthetic fixture, the Rust/WebAssembly kernel consistently outperformed the straightforward TypeScript kernel once the module was warm and inputs were normalized. On small and ordinary incident sets, boundary overhead erased or reversed the advantage. Across the complete cold task, JSON parsing, validation, and rendering remained more significant.

I avoided publishing a single multiplier. It would depend on fixture shape, browser, device, module state, and which phases the timer omitted. The repository kept the benchmark harness and representative results so later changes could be compared under the same definitions.

The result justified keeping the module as an experimental optional worker backend, not making it the only path. The TypeScript implementation remained the reference for clarity and fallback. Shared fixtures protected equivalence.

If future event volumes made the kernel a dominant cost on ordinary workloads, the boundary was ready. If browser engines improved the JavaScript path or the algorithm changed, the module could be removed without rewriting the product state.

That reversibility was more valuable than declaring a language winner.

The browser may not remain WebAssembly's only interesting host. A portable module with a deliberately small import surface could become a useful unit for plugins, edge computation, or server-side jobs that should not inherit an entire process environment. If that direction matures, the import contract and resource limits will matter more than the source language. W93H's tiny kernel is useful preparation precisely because it assumes almost nothing about its host.

The durable result was the boundary

The WebAssembly experiment produced four useful outcomes:

  1. A phase-level performance model replaced one vague complaint.
  2. A worker kept reconstruction off the main thread and improved responsiveness.
  3. A typed numeric contract isolated the ordering kernel from W93H's domain objects.
  4. Shared fixtures made two implementations comparable and replaceable.

Only the fourth outcome required WebAssembly to remain in the repository. The other improvements would survive its removal.

That was the right proportion. WebAssembly 1.0 was a meaningful Web platform milestone. It made a compact, portable low-level execution target broadly real and opened the browser to more implementation choices. It did not change the obligation to locate the bottleneck, count boundary costs, limit authority, and design for the people waiting on the page.

I had approached the experiment asking whether W93H should be rewritten. I left with a smaller module, a better worker, and a clearer TypeScript architecture.

The technology was capable. The rewrite had never been the requirement.