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.
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.
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-mathin C ports. It changes results. Remove it when parity matters.- Byte offsets returned as string indexes. Convert to UTF-16 units.
sort_unstablefor 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.
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.
Related
- Differential testing Wasm against native builds — the same technique across builds.
- Encoding strings across the Wasm boundary — text conversion details.
- Profiling JavaScript to find Wasm candidates — choosing what to port.
- Passing dates and timestamps across the boundary — another source of divergence.
← Back to Porting JavaScript Hot Paths to Wasm