This repository has no description
bez docs firefox-app-units.md
18 kB

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): nscoord is "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/MutationObserver loop reading getBoundingClientRect(); Blink/WebKit's 1/64 LayoutUnit doesn't hit the same float noise. Fixed by removing extra float-precision handling in DOMRect. 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.3vw overriding the page's own 3.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.333vw plus width: calc(50% - 1.666vw) overshoots by 0.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 nscoord range 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_version since commit 6bc3e36, after the original 3ca7323 run.
  • 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.