Back to CSS
CSS
medium
mid

How do you ensure consistent rendering and layout across browsers and devices?

Use a normalize/reset stylesheet, modern layout primitives (flex/grid), feature detection (@supports), autoprefixer + browserslist, polyfills only where needed, and test on real Safari/iOS plus BrowserStack matrix.

7 min read·~15 min to think through

Cross-browser bugs are usually one of three things: CSS vendor differences, JS API gaps, or Safari/iOS being Safari/iOS. The senior answer is process-driven (browserslist + autoprefixer + CI testing) layered with knowledge of common foot-guns.

1. Define your support matrix. browserslist in package.json drives autoprefixer, Babel, and modern bundlers. A typical SaaS:

json
"browserslist": [
  ">0.5%",
  "last 2 versions",
  "not dead",
  "not IE 11"
]

Everything downstream — vendor prefixes, transpilation level, polyfill set — derives from this list.

2. Reset / normalize. Browsers ship different default styles. Pick one of: (a) Eric Meyer reset (zeroes everything), (b) normalize.css (preserves useful defaults, fixes inconsistencies), (c) Tailwind preflight (modern reset). Never ship without one.

3. Use modern layout, not floats/hacks. Flexbox (95%+ support including Safari) and Grid (95%+) cover almost every layout. Hand-rolled positioning is where browsers diverge most.

4. Vendor prefixes via autoprefixer. Plug into PostCSS; it reads browserslist and adds -webkit-, -moz- etc. only where needed. Don't write prefixes by hand.

5. Feature detection over user-agent sniffing.

css
@supports (backdrop-filter: blur(10px)) {
  .modal-bg { backdrop-filter: blur(10px); }
}
ts
if ("IntersectionObserver" in window) { /* use it */ } else { /* fallback */ }

UA strings lie (Edge claims to be Chrome and Safari), break with new browser releases, and miss the actual question.

6. Polyfills only where needed. Don't ship 150KB of core-js to modern browsers. Use:

  • @babel/preset-env with useBuiltIns: "usage" — adds polyfills only for syntax/APIs the code uses.
  • <script type="module"> for modern browsers, nomodule fallback for old — the module/nomodule pattern.
  • Native Promise, fetch, URL are everywhere now; don't polyfill in 2024+.

7. The Safari / iOS tax. Safari often lags 6–18 months on web platform features and has its own bugs:

  • 100vh on iOS includes the address bar — use 100dvh (dynamic viewport).
  • Date input styling is locked.
  • position: sticky glitches inside scroll containers.
  • Touch event quirks; momentum scroll on overflow: scroll containers.
  • <details> / <dialog> shipped late.

Test on a real iOS device or Xcode Simulator. Chrome DevTools "iPhone" mode does not catch these.

8. Responsive design. Mobile-first media queries, clamp() for fluid type, container queries (@container) for component-level responsiveness (Safari 16+). Test at break points: 360, 768, 1024, 1440.

9. Testing.

  • Unit/integration: jsdom (limited; no layout).
  • Visual regression: Chromatic, Percy, or Playwright screenshots — catch layout drift across browsers.
  • BrowserStack / Sauce Labs / Playwright cloud for the full matrix in CI.
  • Real-device testing for iOS Safari at minimum.

10. Source map your bug reports. Sentry / Datadog with browser metadata helps you see "this layout shift only happens on Safari 16.0 on iOS 16" — instead of guessing.

Common bug patterns to know.

  • Different default <button> styles → reset.
  • Flexbox gap worked in Chrome before Safari (Safari 14.1+).
  • iOS rubber-band scroll exposes a "blank" backdrop — set overscroll-behavior: contain.
  • Rendering subpixel differences in SVG between Firefox and Chrome — usually fine, sometimes hairline gaps; shape-rendering: crispEdges helps.
  • Rounded corners on <select> only on macOS Chrome — workaround with custom dropdown.

Code

json
// package.json
{
  "browserslist": [
    ">0.5%",
    "last 2 versions",
    "Firefox ESR",
    "not dead"
  ]
}
// postcss.config.js → require('autoprefixer')
// @babel/preset-env reads the same list automatically.
Browserslist + autoprefixer drive most of this automatically

Follow-up questions

  • Why feature-detect instead of UA-sniff?
  • What's the module/nomodule pattern?
  • How do you handle iOS Safari's 100vh issue?
  • How do you set up visual regression testing?

Common mistakes

  • Hard-coding vendor prefixes — autoprefixer does it correctly.
  • Sniffing user agent — breaks on new browser releases.
  • Polyfilling everything indiscriminately — bloats modern bundles.
  • Skipping real iOS testing because 'Chrome DevTools iPhone mode is fine.'

Performance considerations

  • Module/nomodule sends modern code to modern browsers — half the bundle size.
  • Avoid CSS hacks that force expensive recalcs (e.g., generic * selectors).

Edge cases

  • Safari rounds subpixel layout differently — pixel-perfect designs need rem-based sizing.
  • Flex `gap` not in older Safari — fall back to margin until you can drop support.
  • Container queries are Safari 16+ — use @supports to feature-test before relying.

Real-world examples

  • Stripe Elements ships polyfills targeted at their browserslist; Vercel uses Playwright cloud for cross-browser regression.

Senior engineer discussion

Senior signal: process (browserslist, autoprefixer, visual regression) over case-by-case fixes, and naming the iOS-specific quirks.

Related questions