Keeping JavaScript and Wasm Results Identical

This page answers one task: you have ported a JavaScript function to WebAssembly, and on some inputs the two disagree — a total off in the last decimal, a different sort order, a string that differs by one character — and you need both implementations to produce identical results, or to know precisely where and why they differ.

Prerequisites

  • [ ] The original JavaScript implementation, kept runnable.
  • [ ] The Wasm port with the same public API, as in porting a JavaScript parser to Rust Wasm.
  • [ ] A test runner that can call both, such as Vitest in Node.

Where two correct implementations diverge

Disagreements after a port are rarely random. They come from a short list of places where JavaScript and compiled languages define arithmetic, text and ordering differently. JavaScript numbers are 64-bit floats; Rust and C use exact integer types that wrap, saturate or panic on overflow, while JavaScript silently loses precision above 2⁵³. Floating-point operations are deterministic in both, but the order of operations is not guaranteed to match: a sum accumulated in a different order, a fused multiply-add the compiler introduced, or Math functions with different internal implementations change the last bits. JavaScript strings are UTF-16; Rust strings are UTF-8, so lengths, indexes, slicing and case conversion behave differently around non-ASCII text. Array.prototype.sort is stable; sort_unstable is not. And formatting — toFixed, toString, locale-aware comparison — has no direct equivalent in compiled standard libraries.

The fix is never to guess. Run both implementations on many inputs, find the disagreements, classify each into one of these causes, and decide per case whether to match JavaScript exactly or to accept and document the difference.

Common sources of JavaScript and Wasm divergence Floating-point results differ when operation order changes or FMA is used. Integers differ above 2 to the 53rd or on overflow. Strings differ in length units, slicing and case mapping. Sorting differs in stability. Formatting differs in rounding rules and locale handling. area typical divergence how to match float arithmetic last-bit differences same operation order large integers precision lost in JS BigInt or strings string lengths UTF-16 vs UTF-8 units convert units explicitly sorting unstable order of equal keys use a stable sort number formatting rounding and locale format in JS

Step 1 — build a differential test harness

Call both implementations on the same inputs and compare deeply:

import { parse as parseJs } from "./legacy/parse.js";
import { parse as parseWasm } from "./wasm/parse.js";
import { isDeepStrictEqual } from "node:util";

export function compare(input) {
  const run = (f) => { try { return { ok: f(input) }; } catch (e) { return { err: e.name + ": " + e.message }; } };
  const a = run(parseJs), b = run(parseWasm);
  if (!isDeepStrictEqual(a, b)) return { input, js: a, wasm: b };
  return null;
}

Feed it real data first — a corpus of production inputs — and then generated inputs with property-based testing, which explores edge cases humans do not think of, as in property-based testing for Wasm modules. Minimise each failing input before investigating; a three-character reproduction is far easier to reason about than a 2 MB file.

Step 2 — match floating-point behaviour

Float results match bit-for-bit when both sides perform the same IEEE-754 operations in the same order. Reproduce the JavaScript’s order exactly — a left-to-right sum stays a left-to-right sum, not a pairwise or SIMD reduction — and avoid compiler transformations that change results. Rust does not contract a * b + c into a fused multiply-add unless asked, and WebAssembly has no implicit FMA, so most Rust ports match naturally; C compiled with -ffast-math does not, so remove that flag. Math.sin, Math.exp and friends differ between engines already, so for transcendental functions decide on a tolerance rather than exact equality, and document it. When the port uses SIMD for speed, results may legitimately differ in the last bits; compare with a relative tolerance such as 1e-12 and make sure downstream logic does not depend on exact equality.

Step 3 — match integers and 64-bit values

JavaScript represents integers above 2⁵³ inexactly, so a JavaScript implementation that sums large counters or parses big ids was already losing precision — and an exact Rust port now produces different, more correct numbers. Decide which behaviour you want. For identifiers, keep them as strings or BigInt on both sides. For arithmetic that wrapped implicitly in JavaScript via | 0 or >>> 0, use Rust’s explicit wrapping_add and as u32 to match. For overflow that would panic in a Rust debug build, decide whether wrapping, saturating or an error is correct. 64-bit values at the boundary are covered in passing 64-bit integers with BigInt.

Step 4 — match strings and Unicode

Text is where most ported-code bugs hide. JavaScript length, indexes and slice count UTF-16 code units; Rust counts bytes; neither counts what a user sees as characters. If the JavaScript API returns positions, the port must convert byte offsets to UTF-16 offsets before returning them. Case conversion differs too: toLowerCase uses full Unicode case mapping, Rust’s to_lowercase does as well, but to_ascii_lowercase does not. Normalisation (NFC versus NFD) affects equality of accented characters. Sorting strings with localeCompare depends on locale data that compiled code does not have — keep locale-aware comparison in JavaScript, or use an ICU-based library in both implementations. Test with inputs containing emoji, combining marks, right-to-left text and surrogate pairs.

Investigating one divergence The differential harness finds an input where results differ. The input is minimised, the difference classified as float, integer, string, ordering or formatting, and a decision made to match JavaScript or document the change, followed by a regression test for the minimised case. harness finds a diff corpus or generated minimise the input smallest failing case classify the cause float / int / text / order match or document explicit decision add regression test keep it fixed

Step 5 — match ordering and formatting

Where the JavaScript code sorts, check whether ties occur. Array.prototype.sort is stable since ES2019, so equal keys keep their input order; Rust’s sort is also stable, but sort_unstable — often chosen for speed — is not. Use the stable variant or add a tiebreaker. For number formatting, the simplest way to match toFixed, toPrecision and Intl.NumberFormat exactly is to keep formatting in JavaScript: return numbers from Wasm and let the wrapper format them. Reimplementing JavaScript’s rounding rules in Rust is possible but subtle — toFixed rounds based on the exact binary value, which surprises people in both languages.

Deciding when “different” is acceptable

Not every difference must be eliminated. The port may be more correct: exact 64-bit integers instead of rounded floats, proper Unicode handling where the JavaScript was buggy. Those are behaviour changes, and like any behaviour change they should be deliberate, documented and communicated — a release note, a changelog entry, a migration guide if callers depend on the old values. Record each accepted difference as an explicit exception in the differential harness, with a comment explaining why, so the harness still fails on any new divergence. What must never happen is an unexamined difference reaching users; the harness exists to make every one of them visible.

Running the comparison in production

Tests cover the inputs you have; production covers the ones you do not. During rollout, run both implementations for a sample of real calls and report mismatches with the input’s shape (never its content, unless users consented), as described in shipping a ported module behind a feature flag. A mismatch rate of zero over a few days of real traffic is strong evidence of parity; a non-zero rate points at an input class your corpus lacked, and the reports tell you what to add to it.

Dates, randomness and environment

A few inputs are invisible in function signatures. Code that reads the current time, a random number or the locale produces different results on every run, so differential tests must inject those values on both sides rather than letting each implementation read its own. Pass timestamps, seeds and locale identifiers as explicit parameters during testing. Time zones are a classic trap: JavaScript Date uses the system time zone database, while compiled code may have none at all and treat everything as UTC.

Expected output

The differential harness runs 50,000 corpus inputs and 100,000 generated ones with zero unexplained differences; two documented exceptions remain — exact 64-bit totals and corrected Unicode case folding — each with a regression test and a release note.

Gotchas

  • SIMD reductions changing float sums. Reorder carefully or compare with a tolerance.
  • -ffast-math in C ports. It changes results. Remove it when parity matters.
  • Byte offsets returned as string indexes. Convert to UTF-16 units.
  • sort_unstable for speed. Equal keys reorder. Use a stable sort or tiebreak.
  • Reimplementing toFixed. Keep formatting in JavaScript.

Performance note

Running both implementations for parity checking doubled the work for sampled calls only; at a 1% sampling rate the production overhead was under 2%. The differential test suite ran 150,000 comparisons in about 40 seconds in Node.

Divergences found by each kind of test input Number of distinct divergences between the JavaScript and Wasm implementations found by hand-written unit tests, a production input corpus, and property-based generated inputs during one port. distinct divergences found hand-written unit tests 2 production corpus 5 property-based inputs 9

Frequently Asked Questions

Is WebAssembly floating-point deterministic? Yes, except for NaN bit patterns and relaxed SIMD; the same operations in the same order give the same results across engines.

Why do Math.sin results differ from Rust’s sin? They are different implementations with different last-bit accuracy. Compare with a tolerance.

Should the port reproduce JavaScript bugs? Only if callers depend on them. Otherwise fix them deliberately and document the change.

How do I compare objects with float fields? Write a comparison that uses a tolerance for floats and exact equality elsewhere.

Does this apply to C ports too? Yes — plus watch for undefined behaviour and compiler flags that change arithmetic.

How do I compare implementations that use the current time? Inject the time as a parameter in both, so tests are deterministic.

← Back to Porting JavaScript Hot Paths to Wasm