Firefox's app-unit rounding is a confirmed, if narrow, web-compat problem #
A cross-browser layout verification harness found that Firefox diverges from Chromium and WebKit by up to 0.0375 px on deeply nested fractional-percentage widths, with Chromium and WebKit agreeing with each other. On its own, a sub-pixel geometry difference proves nothing: it only matters once it changes something a user or a page's script can observe.
Instrumented follow-up work found that it does. Three Mozilla-confirmed Firefox-only
breakage reports — a page that hangs the browser, a flex grid that wraps one row too
early, a product grid that collapses from two columns to one — are all diagnosed by
Mozilla's own engineers as this same rounding difference, and all three reproduce in a
few lines of HTML against the exact engine versions below. Automated sweeps of nearby
shapes (plain integer-width floats, inline-blocks and flex-wrap rows; overflow: auto
scrollbars; line breaking) found nothing further: every case that diverges, confirmed
report or minimal reproduction alike, needs a computed width that lands off the pixel
grid — the kind gap, calc() or vw arithmetic produces, not the round pixel values a
test author types by hand. That is also why the sweeps came back empty: they varied only
round-number widths.
flowchart LR
calc["gap / calc() / vw<br/>gives an off-grid width"] --> g64["Blink, WebKit:<br/>round to 1/64 px"]
calc --> g60["Gecko:<br/>round to 1/60 px"]
g64 --> fit["flex line fit,<br/>offsetWidth rounding"]
g60 --> fit
fit --> diff["a row wraps, a column drops,<br/>a pixel differs — Firefox only"]
Engine versions for every result below, from Playwright 1.63.0 on Linux: Chromium 153.0.8010.12, Firefox 155.0, WebKit 26.6.
Minimal reproductions #
1. A flex row wraps one item early (matches bug 1923245) #
<div style="width:814.333px; display:flex; flex-wrap:wrap; gap:24px">
<div style="flex:0 0 255.45px; height:20px; background:#369"></div>
<div style="flex:0 0 255.45px; height:20px; background:#369"></div>
<div style="flex:0 0 255.45px; height:20px; background:#369"></div>
</div>
Three items at 255.45px plus two 24px gaps sum to 814.35px, 0.017px over the 814.333px container. That deficit is the whole case: whether it clears depends on which pixel grid each engine rounds the item width to.
| Engine | Item width used | Result |
|---|---|---|
| Chromium 153.0.8010.12 | 255.4375px (16348/64) | all three items on one row |
| Firefox 155.0 | 255.4499969482422px (15327/60) | third item wraps to a new row |
| WebKit 26.6 | 255.4375px (16348/64) | all three items on one row |
Identical at device pixel ratio 1, 1.5, 2 and 3 — this is a layout-time (CSS pixel)
effect, not a paint-time one. Raw per-engine rects: experiments/firefox-compat/results/known-bugs.json.
2. offsetWidth disagrees by a whole pixel (minimal case, not tied to a specific bug) #
<div style="width:232px">
<div style="width:75%"><div style="width:75%"><div style="width:75%">
<div style="width:75%"><div style="width:75%"><div style="width:75%">
<div style="width:75%"><div style="width:75%"><div style="width:75%">
<div style="width:75%"><div style="width:75%">
<div style="width:75%;height:15px" id="leaf"></div>
</div></div></div></div></div></div></div></div></div></div></div>
</div>
<script>document.getElementById('leaf').offsetWidth</script>
Twelve levels of width: 75%, each resolved against its parent's used (already rounded)
width, the same accumulation shape as crates/harness/src/cases.rs's
recipe_nesting_depth. The leaf's exact width lands on a rounding tie in one engine and
short of it in the other two:
| Engine | getBoundingClientRect().width |
offsetWidth |
|---|---|---|
| Chromium 153.0.8010.12 | 5.484375px (351/64) | 5 |
| Firefox 155.0 | 5.5px (330/60) | 6 |
| WebKit 26.6 | 5.484375px (351/64) | 5 |
offsetWidth/clientWidth/scrollWidth are what scripts actually read — not the
sub-pixel getBoundingClientRect value the base harness compares — and CSSOM View rounds
a .5 tie up. Every engine follows that rounding rule correctly; they disagree because
they don't agree on the sub-pixel value going into it. Same result at all four DPRs.
Raw data: experiments/firefox-compat/results/integer-rounding.json.
3. A percentage/vw grid drops a column (approximates bug 1959132 / webcompat.com#152559) #
<div style="width:100vw; display:flex; flex-wrap:wrap; gap:3.333vw">
<div style="width:calc(50% - 1.666vw); height:20px; background:#369"></div>
<div style="width:calc(50% - 1.666vw); height:20px; background:#369"></div>
<div style="width:calc(50% - 1.666vw); height:20px; background:#369"></div>
<div style="width:calc(50% - 1.666vw); height:20px; background:#369"></div>
</div>
gap: 3.333vw plus width: calc(50% - 1.666vw) overshoots the row by 0.001vw — at an
800px viewport, about 0.008px. Chromium and WebKit absorb it and keep two items per row;
Firefox does not, and cascades into dropping every item to its own row rather than the
clean single-column layout the live Samsung page showed (this is an approximation of the
site's actual computed values, not the site itself — the shape, not the exact spacing, is
what carries over). Reproduced identically at DPR 1, 1.5, 2 and 3. Raw data:
experiments/firefox-compat/results/known-bugs.json.
The mechanism, now instrumented directly #
Gecko represents lengths in app units, 1/60 of a CSS pixel (nscoord, a 32-bit integer).
Blink and WebKit both use LayoutUnit, 1/64 of a CSS pixel. Percentage resolution rounds
to the engine's grid at every level of nesting, so error accumulates with depth, and the
engine on the coarser grid drifts first — this was the base harness's finding, but it
was not instrumented directly.
It now has been. Reproducing the harness's own worst-case shape (twelve levels of width: 83.333%, unconstrained container — the shape recipe_nesting_depth generates in
crates/harness/src/cases.rs, not example 2's 75%-in-a-232px-container case) and reading
the raw sub-pixel width directly:
dpr=1 spread=0.005203px chromium: 89.671875 firefox: 89.66667175292969 webkit: 89.671875
dpr=1.5 spread=0.005203px chromium: 89.671875 firefox: 89.66667175292969 webkit: 89.671875
dpr=2 spread=0.005203px chromium: 89.671875 firefox: 89.66667175292969 webkit: 89.671875
dpr=3 spread=0.005203px chromium: 89.671875 firefox: 89.66667175292969 webkit: 89.671875
89.671875 × 64 = 5739 exactly; 89.66667175292969 × 60 ≈ 5380 (the residual is
float round-trip noise from app units through a double, not from the grid itself). The
spread is identical at every device pixel ratio, confirming the mechanism is a CSS-pixel
grid, not anything resolution-dependent — device pixel ratio only matters to paint-time
snapping, not to this. Script: experiments/firefox-compat/deep-nesting-direct.mjs.
Real-world reports #
None of these were found by searching webcompat.com or the CSSWG for the phrase "app units" — that text appears in Bugzilla comments, not in the original reports. They were found by following Bugzilla's own bug-dependency graph from the tracking bug below. None are closed WONTFIX; Mozilla accepted every one as a real, if awkward to fix, cause.
Confirmed, Firefox-only, root-caused to app-unit rounding:
- bug 1640441 — Slack's web app
hung the entire Firefox session (not just the tab) when hovering or clicking certain
links, reproducible every time, forcing users to restart Firefox multiple times a day.
RESOLVED FIXED. Root cause (Mozilla engineer emilio, comment 28):
nscoordis "not precisely representable as a float, and slack introduc[es] minor floating point errors that cause us to switch layout units back and forth" across a resize/MutationObserverloop readinggetBoundingClientRect(); Blink/WebKit's 1/64LayoutUnitdoesn't hit the same float noise. Fixed by removing extra float-precision handling inDOMRect. The underlying instability recurred (comment 37) before Slack's own JS was also fixed. - bug 1717890 — the same hang recurring. RESOLVED WORKSFORME. Emilio: "We do layout in app units, which are 1/60th of a CSS pixel, and can't be represented exactly by a float. Slack is relying on the values we return to be roundtripping... in a way that they just can't right now."
- bug 1673418 — the same hang from a LinkedIn link inside a Slack comment. RESOLVED DUPLICATE of 1640441.
- bug 1923245 — Google Store's "compare cameras" page shows broken layout at certain window sizes, Firefox only. NEW (open). Mozilla engineer dholbert's diagnosis is example 1 above almost verbatim: three 255.45px flex items plus two 24px gaps against an 814.333px container, with Firefox's 1/60 rounding pushing the remainder up where Chrome's 1/64 rounding pushes it down. He notes the direction is arbitrary — a different width would overflow Chrome and not Firefox — but the breakage, as shipped on a real site, is not.
- bug 1959132 — Samsung's product
grid shows one column instead of two on Firefox Mobile, via
webcompat.com/web-bugs#152559.
VERIFIED FIXED, by a Mozilla-shipped CSS site intervention (
gap: 3.3vwoverriding the page's own3.333vw) that forced the two-column layout Chrome showed by default; the intervention was later removed once Samsung's own site layout changed. Dholbert's diagnosis:gap: 3.333vwpluswidth: calc(50% - 1.666vw)overshoots by0.001vw, "a site bug that's fragile in all browsers... depending on... the browser's internal fixed-point subpixel length representation."
A secondary, narrower consequence of the same fixed-point representation:
- bug 1700865 — the maximum pinch-zoom
level is hard-coded relative to how much of the 32-bit
nscoordrange app units consume, so it depends on the app-units-to-device-pixel ratio. NEW (open). A real but much narrower user-facing effect than the layout bugs above.
Related, but a different rounding question — not the 1/60-vs-1/64 unit denominator:
- csswg-drafts#5210 — when border-width
rounding to the device pixel grid happens (computed-value time in Gecko/WebKit, paint
time in Blink), filed by emilio. CLOSED, accepted by CSSWG resolution. Cited here only
as evidence that engines already treat sub-pixel rounding as a live interop question;
it is not about app units versus
LayoutUnit.
What was not found: no CSSWG issue names the 1/60-vs-1/64 app-unit precision difference directly. No webcompat.com/web-bugs report states "app unit" or "app-unit" in its own text; the five genuine cases above surfaced through Bugzilla's dependency graph, not through text search on the original reports.
Prior Mozilla discussion of a finer unit #
bug 1719314, "Consider changing nscoord (app units) to be something exactly representable as a float," is the tracking bug for all four layout reports above (it lists them as blocking it) and is still open (NEW, last activity June 2026). Filed by emilio directly off the Slack hang:
Chrome uses 1/64 of a CSS pixel, which does the trick. This is the only reasonable way I can think to prevent issues like bug 1717890, where slack gets confused due to floating point inaccuracies from the app units -> fp -> app units conversions.
Asked for the tradeoff against 1/64, emilio: "Pros of 1/60 are described in bug 177805 (basically, it allows to represent thirds / fifths exactly). Pros of 1/64 are that math on them is faster (e.g., halving is just a bit shift) and that they're exactly representable as floats." A work-in-progress patch ("Make app units 1/64th of a CSS pixel") was posted 2024-08-26 and, as of this research, has not landed.
The 1/60 choice itself dates to bug 177805
(2002, RESOLVED FIXED), where proposals ranged from 1/100 to 1/720 before roc settled on
1/60 as a tradeoff that still represents whole CSS pixels exactly at every integer device
pixel ratio from 1 to 6, while leaving "over a million lines of 10px text" of headroom
inside the 32-bit nscoord range. dbaron argued at the time for 1/20 instead, specifically
to buy back range against nscoord (int32) overflow, since fractions like thirds and
sixths already can't be represented exactly in decimal CSS regardless of the denominator
chosen — the precision-versus-range tradeoff was explicit from the start, twenty years
before it resurfaced as bug 1719314.
The ask #
Land bug 1719314, or, if the full unit-width change remains blocked on the nscoord range
question, match Blink/WebKit's rounding at the specific points the confirmed reports hit:
percentage resolution feeding a flex-wrap line-fit decision, and gap/calc()
arithmetic on percentage and viewport-relative values. Those are exactly the shapes in
examples 1 and 3 above and in bugs 1923245 and 1959132 — narrower than a full unit change,
and directly targeted at where real pages have broken.
What did not diverge #
Automated sweeps around the same shapes found nothing beyond the confirmed reports and the minimal cases above:
| Sweep | Cases | Divergent hits |
|---|---|---|
Fractional-percentage siblings (thirds, sevenths, sixths, 8.33333% × 12) as floats, inline-blocks and flex-wrap rows, integer container widths 60–2000px, no gap |
11,652 | 0 |
Twelve-level nested chain in overflow: auto, integer container widths 60–1000px, checked for a spurious scrollbar |
5,646 | 0 |
| Twelve-level nested chain wrapping a line of text, integer container widths 100–800px, checked for a wrap-boundary shift | 2,103 | 0 |
Every confirmed report and every minimal case that did diverge needed a non-integer
computed width — from gap, calc() or vw arithmetic, not an authored literal. That is
consistent with the base harness's own finding that round test values hide the effect
completely, and explains why the divergence is rare in the wild despite being real:
authored pixel values are usually round; computed ones, especially under flex-wrap with
gap or responsive calc()/vw grids, often are not.
Pixel-snapped borders and backgrounds (inconclusive). A screenshot sweep of touching,
alternating-color flex items at DPR 1, 1.5, 2 and 3 found 16 divergent hits out of 84
cases, but they don't isolate the app-unit mechanism: at DPR 1.5 the divergence is
sometimes Firefox and WebKit against Chromium, sometimes Firefox alone, sometimes WebKit
alone; at DPR 2 and 3 it appears only at one specific non-integer container width
(700.333px) and names Chromium as the outlier. This is more consistent with ordinary
paint-time anti-aliasing noise at fractional device-pixel boundaries, present in some
form in all three engines, than with the layout-time 1/60-vs-1/64 grid the rest of this
document is about. Raw data: experiments/firefox-compat/results/pixel-seam.json.
Harness methodology (carried over, shortened) #
The base harness renders 235 generated block-box documents in Chromium, Firefox and WebKit
and compares box sizes and parent-relative offsets at a 1/60 px tolerance
(docs/geometry-verification.md). The 699-of-705 result was measured at commit 3ca7323.
What it taught: a fixed tolerance fails silently on deep trees; two engines cannot say
which is wrong, three can; loosening the threshold blinds the check to real sub-pixel
bugs; round test values hide the effect completely.
Caveats #
- Playwright's WebKit is not Safari. Every "Chromium and WebKit agree" result is about that patched build on Linux, not shipping Safari.
- Example 3 approximates a mobile report on desktop Firefox at an 800px viewport; the shape carries over, the exact spacing does not.
- Text cases are confounded by fonts each engine substitutes on this host; the line-breaking sweep found nothing, so it did not matter here.
- Engine versions are Chromium 153.0.8010.12, Firefox 155.0, WebKit 26.6 via
Playwright 1.63.0; every dump has carried
engine_versionsince commit6bc3e36, after the original3ca7323run. - The pixel-boundary sweep (
pixel-seam.mjs) is inconclusive, not a fourth confirmed case.
Reproducing #
The base harness:
npm --prefix oracle install
npx playwright install chromium firefox webkit
cargo run --release --bin crosscheck
The web-compat experiments in this document:
npm --prefix experiments/firefox-compat install
node experiments/firefox-compat/known-bugs.mjs # examples 1 and 3
node experiments/firefox-compat/integer-rounding.mjs # example 2, plus the sweep
node experiments/firefox-compat/deep-nesting-direct.mjs # the mechanism section
node experiments/firefox-compat/wrap-sweep.mjs
node experiments/firefox-compat/scrollbar-sweep.mjs
node experiments/firefox-compat/line-breaking.mjs
node experiments/firefox-compat/pixel-seam.mjs
Each writes raw results to experiments/firefox-compat/results/*.json (gitignored) and
prints every divergent case to stdout. See experiments/firefox-compat/README.md for what
each script tests. WebKit additionally needs 188 system packages (GTK4, graphene, the
gstreamer stack) installed with root privileges before Playwright can launch it.
crates/harness/src/bin/crosscheck.rs drives the base harness run,
crates/harness/src/compare.rs holds its comparison and voting, and docs/dump-format.md
specifies what it collects.