Debugging Layer Differences Between Chrome, Firefox and Safari
A component looks right in Chrome and wrong in Safari, the CSS is full of @layer, and the obvious hypothesis is an engine difference in layer resolution. It almost never is. Layer ordering is one of the more interoperable parts of modern CSS, so a cross-engine difference in a layered codebase is usually something else wearing a layer costume. This workflow finds which, extending testing cascade layers across browsers in the browser support, compatibility and migration reference.
Prerequisites
Access to all three browsers on the same build, and familiarity with how the cascade sorts declarations — origin first, then layer, then specificity.
Step 1: Compare the registered layer order, engine by engine
Start here, because a difference at this level explains everything downstream.
// Paste into each browser's console on the same URL. WHY: if the two engines
// registered different orders, the bug is in the BUILD or the loading, not in
// the cascade — different chunks, a different bundle, or an import that
// resolved differently.
(() => {
const names = [];
const walk = (rules) => {
for (const r of rules) {
if (r.constructor.name === 'CSSLayerStatementRule') names.push(...r.nameList);
else if (r.constructor.name === 'CSSLayerBlockRule') {
if (r.name) names.push(r.name);
walk(r.cssRules);
} else if (r.cssRules) walk(r.cssRules);
}
};
for (const s of document.styleSheets) {
try { walk(s.cssRules); } catch { /* cross-origin — not ours */ }
}
return [...new Set(names)];
})();What this does: produces a comparable list in every engine. Identical lists mean the cascade inputs are the same and the difference is downstream; different lists mean you are debugging a build or delivery problem.
Step 2: Compare the computed value, not the appearance
// Run in both engines on the same element. WHY named longhands: shorthand
// serialisation differs between engines, so `padding` can read differently
// while every side is identical.
const el = document.querySelector('[data-testid="card"]');
JSON.stringify(
['paddingTop', 'borderTopWidth', 'color', 'fontFamily', 'display']
.reduce((acc, p) => ({ ...acc, [p]: getComputedStyle(el)[p] }), {}),
null, 2
);What this does: converts “it looks different” into a specific property and two specific values, which is the difference between a two-minute diagnosis and an afternoon.
Step 3: Rule out the four causes that are never layer bugs
/* 1 · DIFFERENT USER-AGENT DEFAULTS
Safari and Chromium disagree on form-control metrics, and the UA rule is
only visible when no author rule matches.
→ Fix: an explicit author declaration in `reset`, not a layer change. */
/* 2 · FONT METRICS
A different fallback font resolves to a different line-height, which shifts
everything below it.
→ Fix: self-host the font and set an explicit line-height. */
/* 3 · A FEATURE INSIDE THE LAYER, NOT THE LAYER ITSELF
`@container`, `:has()`, `text-wrap: balance`, `color-mix()` — if a rule
uses something one engine lacks, that DECLARATION is dropped while the
layer works perfectly.
→ Fix: check support for the value, not the at-rule. */
/* 4 · A DIFFERENT BUNDLE
Differential serving, a `@supports` gate, or a polyfilled build for older
targets means the engines are not running the same CSS at all.
→ Fix: compare the fetched stylesheet URLs, not the source. */What this does: eliminates the overwhelming majority of reports. Cause 3 is the most deceptive, because the symptom appears only inside layered rules — which makes layers look responsible.
Step 4: Use each browser’s layer surface
Chrome / Edge. The Styles panel annotates each rule with its layer name. Clicking the annotation opens a layer-order view listing the registered stack. The Computed tab names the winning rule, including its layer.
Firefox. The Rules pane groups declarations under a heading per layer, in cascade order — the fastest way to see the whole stack for one element without leaving the panel. Struck-through declarations show what each layer lost.
Safari. Rules carry their layer name in the Styles sidebar, with less dedicated tooling around it. When the presentation is ambiguous, fall back to the CSSOM query from Step 1, which behaves identically everywhere.
// Cross-engine equivalent of "which layer won": temporarily disable the
// suspected rule and see whether the computed value changes.
// WHY this rather than a DevTools feature: no engine exposes provenance for
// a resolved value, so substitution is the portable technique.
const sheet = [...document.styleSheets].find((s) => s.href?.includes('index.css'));
const rule = [...sheet.cssRules].find((r) => r.cssText?.includes('.card'));
console.log('before', getComputedStyle(el).paddingTop);
rule.style.padding = ''; // neutralise it in place
console.log('after ', getComputedStyle(el).paddingTop);Verification
- Confirm the layer order lists match character for character across engines.
- Confirm the computed value of the disputed property differs, and that no other property does — a broad difference points at fonts or the bundle rather than one rule.
- Confirm the same stylesheet is being served:
# Compare what each engine actually fetched. Different sizes mean different
# bundles, which means the cascade was never the variable.
curl -sI https://example.com/assets/index.css | grep -i "content-length\|etag"- If everything matches and the values still differ, reduce to a minimal case before concluding it is an engine bug.
Troubleshooting
Firefox groups rules by layer but Chrome does not show the same order. : Chrome lists rules by cascade precedence within the panel rather than grouping them; both are showing the same resolution differently. Trust the Computed tab and the CSSOM query over either presentation.
Safari shows no layer annotation at all.
: Check the Safari version — layer annotations arrived after the feature itself. On an older build, use the CSSOM query, which works wherever @layer does.
A rule is struck through in one engine and applied in another.
: A competing declaration exists in only one of them, which points at a different bundle or a @supports branch rather than a layer difference.
The difference disappears with DevTools open.
: Something is responding to viewport size or to prefers-reduced-motion, both of which DevTools can change. Reproduce with the panel undocked, or emulate the media feature explicitly.
Everything matches but the page still looks different. : Compare a rendered screenshot rather than reasoning further — the difference may be in a property you have not queried, and a component-level screenshot diff will find it faster than another round of console work.
Complete working example
// src/dev/layer-diagnose.js — paste into any console, in any engine.
// WHY one script for all three: the engines' DevTools differ, but the CSSOM
// and getComputedStyle behave identically, so a single output can be compared
// side by side without translating between panels.
export function diagnose(selector) {
const el = document.querySelector(selector);
if (!el) return { error: `no element matches ${selector}` };
// 1 · the registered stack
const layers = [];
const walk = (rules) => {
for (const r of rules) {
const kind = r.constructor.name;
if (kind === 'CSSLayerStatementRule') layers.push(...r.nameList);
else if (kind === 'CSSLayerBlockRule') {
if (r.name) layers.push(r.name);
walk(r.cssRules);
} else if (r.cssRules) walk(r.cssRules);
}
};
for (const s of document.styleSheets) {
try { walk(s.cssRules); } catch { /* cross-origin */ }
}
// 2 · every rule that matches this element, with its layer path
const matching = [];
const collect = (rules, path = []) => {
for (const r of rules) {
const kind = r.constructor.name;
if (kind === 'CSSLayerBlockRule') {
collect(r.cssRules, r.name ? [...path, r.name] : path);
} else if (r.cssRules && kind !== 'CSSStyleRule') {
collect(r.cssRules, path); // @media, @supports, @container
} else if (r.selectorText) {
try {
if (el.matches(r.selectorText)) {
matching.push({ layer: path.join('.') || '(unlayered)', rule: r.cssText });
}
} catch { /* :has() or other selector this engine cannot match */ }
}
}
};
for (const s of document.styleSheets) {
try { collect(s.cssRules); } catch { /* cross-origin */ }
}
// 3 · the resolved result, in longhands so engines agree on serialisation
const cs = getComputedStyle(el);
const computed = {};
for (const p of ['paddingTop', 'marginTop', 'borderTopWidth', 'color',
'backgroundColor', 'fontFamily', 'fontSize', 'lineHeight',
'display', 'position']) {
computed[p] = cs[p];
}
// WHY the sheet list: a differing bundle is the most common non-layer cause,
// and it is invisible unless you look at what was actually fetched.
const sheets = [...document.styleSheets].map((s) => s.href).filter(Boolean);
return { engine: navigator.userAgent, layers: [...new Set(layers)],
sheets, matching, computed };
}Frequently Asked Questions
Do engines actually differ in how they resolve cascade layers?
Very rarely. Layer ordering is well covered by the web-platform test suite and passes across shipping engines, so a difference in resolution is an unusual finding rather than a starting assumption. What differs is everything around it — user-agent defaults, font fallbacks, support for the values used inside layered rules, and which bundle each browser was served. Those four account for nearly every report.
Which browser has the best DevTools for cascade layers?
Firefox’s Rules pane groups declarations under a heading per layer in cascade order, which shows the whole stack for one element at a glance. Chrome annotates each rule with its layer and offers a dedicated layer-order view, which is better when you need the manifest rather than one element’s resolution. Safari displays layer names but has less tooling built around them, so the CSSOM query is the portable fallback.
How do you tell a layer problem from a specificity problem?
Look at whether the two competing rules sit in different layers. If they do, specificity played no part — layer order decided the winner, and adding weight to the loser will change nothing. If they share a layer, the layer system is not involved at all and you are debugging an ordinary specificity or source-order conflict. Answering that one question first saves most of the investigation.
Related
- Testing Cascade Layers Across Browsers — the suite that catches these differences before release
- Debugging Unlayered Author Styles in DevTools — the same workflow for a single-engine conflict
- Cascade Origins, User Agent and User Styles — the origin differences behind most cross-engine reports
- Checking Layer Support With CSS.supports in JavaScript — confirming an engine supports the feature at all
Up: Testing Layers Across Browsers → Browser Support, Compatibility & Migration