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.

Splitting a cross-engine difference into its real cause A flow beginning with a comparison of registered layer order. Different orders lead to a build or delivery problem. Identical orders lead to a computed-value comparison, which either reveals a non-layer platform difference or, rarely, a genuine engine bug worth reducing and reporting. Same registered layer order? no yes build or delivery problem different chunk, import or bundle compare computed values same element, same property non-layer difference UA default · font · feature support genuine engine bug rare — reduce, then report

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);
Four causes that look like layer bugs Four rows pairing a cause with its distinguishing symptom and fix: user-agent defaults show a struck-through author rule absence, font metrics shift vertical rhythm, unsupported values inside a layer drop single declarations, and a different served bundle changes the stylesheet URL. cause distinguishing symptom fix user-agent defaults no author rule matches at all declare it in reset font metrics vertical rhythm shifts, colours match self-host, pin line-height unsupported value ONE declaration missing, layer intact @supports the value different bundle served stylesheet URL or size differs compare Network, not source Layer resolution itself is interoperable — check these four before writing a bug report.

Verification

  1. Confirm the layer order lists match character for character across engines.
  2. 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.
  3. Confirm the same stylesheet is being served:
Three artefacts worth capturing before reporting a bug Three pieces of evidence to collect before filing an engine bug: the registered layer order from each browser, the computed values of the disputed property, and the URL and size of the stylesheet each engine fetched. the registered order per engine identical lists rule out a build difference computed values, in longhands shorthand serialisation differs between engines the stylesheet URL and size per engine a different bundle explains most reports
# 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"
  1. 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.

Up: Testing Layers Across BrowsersBrowser Support, Compatibility & Migration