Back to News & Insights
JavaScript August 18, 2026 · 5 min read

The same Rust gave two different answers, and neither matched JavaScript

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry. Demo...

The same Rust gave two different answers, and neither matched JavaScript

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

I was porting TinyColor, a fifteen-year-old JavaScript colour library, to Rust. The rule I set myself was that the original test suite had to pass unmodified. Not a translation of it. The actual file, byte for byte, sha256-pinned, loading my Rust instead of the JavaScript.

So I compiled the Rust to WebAssembly, put a shim at that path, and let the suite run. Forty-four of forty-five tests passed on the first try.

Two doubles that differ in the last place. That's the smallest possible disagreement between two f64 values, and there's no smaller one to find.

It's also an unusual thing for a test to catch, because most test suites don't compare floats exactly. They use an epsilon. TinyColor's suite asserts the literal value to sixteen decimal places, which struck me as fragile when I first read it and turned out to be the reason I found any of this.

My first theory was rounding. My port has a whole module reproducing JavaScript's numeric quirks, because they bite constantly: Math.round(-1.5) is -1 in JS and -2 in Rust, parseFloat("50%") is 50 rather than an error. I'd already been caught by both. So a rounding difference in luminance felt like more of the same, and I went looking for one.

There wasn't one. The arithmetic was identical. I printed intermediate values at full precision and they matched all the way to the pow call.

Then I ran the same code natively instead of through WebAssembly, mostly out of frustration.

That reframed the whole thing. I'd been looking for a bug in my code, and my code was fine. The disagreement was underneath it.

Math.pow isn't defined to be bit-exact. IEEE 754 mandates correct rounding for add, subtract, multiply, divide and square root. Transcendental functions like pow are explicitly left alone, because a correctly-rounded pow is expensive and nobody wanted to require it.

So every implementation makes its own accuracy-versus-speed trade, and they disagree in the last bit or two: V8 uses its own port of fdlibm Rust on macOS calls the system libm, which is Apple's Rust on wasm32 has no system libm, so it links a MUSL-derived implementation

Since getLuminance only ever sees integer channel values, I could check the whole input space rather than guess. All 256 of them, against V8:

| implementation | matches V8 exactly | |---|---| | Rust std powf (macOS libm) | 207 / 256 | | Rust std powf (wasm32, MUSL) | 225 / 256 | | V8 (fdlibm) | the reference |

Neither one matches. Not "close enough" — they each disagree with V8 on a few dozen of the 256 possible inputs, and they disagree on different ones.

That was the moment the bug stopped being annoying and got interesting. There was no libm I could pick that would be right. Switching to the MUSL version would have fixed 18 more cases and broken others.

toRgb() rounds. So rgb.r is always an integer from 0 to 255. pow never sees anything else. Ever.

That isn't a function call with a continuous domain. It's a lookup with 256 possible inputs, and I'd been treating it as maths because it was written as maths.

So I generated the table. Ran V8 once over all 256 channel values, captured the exact f64 bit patterns, and emitted them as Rust:

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation