Web Development
Cross-Browser Compatibility Bugs in Prod: Why Your Site Dies Only on Safari and How to Stop It
Fix cross-browser and device compatibility bugs: parse-time JS failures, silent CSS drops, Safari viewport quirks, Browserslist drift, and a Playwright CI matrix.
Friday, 11:42 p.m. The sale had been live for forty minutes. Support started pinging: the checkout page loads, the Pay now button does nothing. Dashboards were green. Error rate flat. Chrome on every dev laptop worked fine. Edge too.
Somebody finally dug out an old iPhone stuck on iOS 15 and tapped the button. Nothing. Not an error, not a spinner. Just a dead page that looked perfectly healthy.
iOS conversion had fallen off a cliff and nobody's alerting noticed, because the error reporter lived inside the same bundle that was failing.
That's the whole incident. Here's why it happens and what to put in your repo so it doesn't happen to you.
The root cause: a parse error is not a runtime error#
The culprit was a transitive dependency. Renovate bumped it on Tuesday. The new release shipped a regex with a lookbehind assertion ((?<=\$)\d+), which is valid ES2018. Safari didn't support lookbehind until 16.4.
Here's the part that bites. When an engine hits syntax it can't parse, it doesn't skip the offending function. It rejects the entire script file. Zero lines execute. Your HTML renders, your CSS paints, and the page looks alive. It's a corpse with makeup on.
And the monitoring SDK? Same bundle. It never initialized, so nothing got reported. Silence.
This is one of four failure families you'll meet in the wild, and each one needs a different fix:
- Syntax gaps. The engine can't parse what you shipped. A transpiler (Babel, SWC) can rewrite most syntax. It can't polyfill it.
- API gaps. The syntax parses but
structuredCloneorArray.prototype.atdoesn't exist. Polyfills (core-js) handle this. - CSS silent drops. The browser throws away what it doesn't understand and tells you nothing.
- Platform behavior. Same standards, different decisions: viewport units, storage eviction, autofill, form control chrome.
CSS fails quietly, and sometimes takes friends with it#
An unknown property or value gets dropped. Fine, that's the designed escape hatch. But an unsupported selector inside a comma-separated list invalidates the whole rule:
/* Engines without :focus-visible discard BOTH selectors */
.btn:hover,
.btn:focus-visible {
outline: 2px solid var(--ring);
}
Hover styling vanishes in an old Safari because of a selector you added for keyboard users. Nobody sees it in review.
Platform quirks that aren't about "support" at all#
On iOS, Chrome and Firefox are WebKit wrappers in most of the world. "It works in Chrome on my iPhone" tells you nothing about Blink. Safari's dynamic toolbar makes 100vh taller than the visible area, so a bottom-pinned button lands under the browser chrome. And Safari's ITP evicts script-writable storage after a stretch of inactivity, which means your "remember me" flag in localStorage can quietly disappear.
Identical rendering is the wrong target anyway. Engines rasterize fonts differently. Scrollbars take different widths. Date pickers look nothing alike. Aim for functional equivalence with visual tolerance, and write that down so design and QA argue about the same thing.
Countermeasures#
1. One source of truth for your support matrix#
Drift between tools is the quiet killer. Babel targets one list, Autoprefixer another, the bundler a third. Put it in a single .browserslistrc and make everything read it:
# .browserslistrc
> 0.3%
last 2 versions
Safari >= 15.0
iOS >= 15.0
not dead
Run npx browserslist in CI and commit-diff the output. If a dependency bump changes the resolved list, you want a human to see it.
2. Transpile what you ship, including what you didn't write#
Most configs skip node_modules. That's exactly where the incident came from. Either allowlist the packages that ship modern syntax, or transpile everything and eat the build time:
// webpack.config.js
{
test: /\.m?js$/,
include: [
path.resolve('src'),
/node_modules[\\/](some-ui-lib|date-fns-tz)[\\/]/,
],
use: 'babel-loader',
}
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: '3.38',
bugfixes: true,
}],
],
};
Then add a gate that fails the build if the output outruns your target. es-check does this by parsing the built files:
npx es-check es2017 "./dist/**/*.js"
Wire it into the PR pipeline. A syntax regression now fails a check instead of a checkout.
3. Put the canary outside the bundle#
The reporter must not share a fate with the app. Ship a tiny inline script, written in plain ES5, before anything else:
<script>
(function () {
try {
new RegExp('(?<=a)b'); // lookbehind
new Function('return 1 ?? 2'); // nullish coalescing
} catch (e) {
navigator.sendBeacon('/rum/unsupported', JSON.stringify({
ua: navigator.userAgent,
err: String(e)
}));
document.documentElement.className += ' no-modern-js';
}
})();
</script>
Constructing the regex at runtime means the probe itself can't trigger a parse failure. You get a beacon with the user agent, and you can show a fallback message instead of a dead button.
Then alert on error rate per browser and version, not the global rate. A 100% failure on 4% of traffic is invisible in an aggregate. Slice it.
4. Write CSS the cascade can fall back on#
Declarations the browser can't parse get dropped, so order them from oldest-safe to newest:
.hero {
min-height: 100vh; /* old engines stop here */
min-height: 100dvh; /* engines that know dvh override it */
}
Split selector lists that mix old and new pseudo-classes. And gate bigger features behind @supports, with the fallback layout as the default:
.panel { display: flex; flex-direction: column; }
@supports (container-type: inline-size) {
.panel-wrap { container-type: inline-size; }
@container (min-width: 40rem) {
.panel { flex-direction: row; }
}
}
The base case is the stacked layout. Unsupported browsers get it, and it works.
5. Test in WebKit, every PR#
Playwright ships Chromium, Firefox, and WebKit builds, so you can run the same smoke suite against all of them without a device farm:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/smoke',
retries: 1,
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'iphone', use: { ...devices['iPhone 13'] } },
],
});
Keep the suite small. Login, add to cart, pay. Those are the paths that cost money when they break.
The trade-offs#
None of this is free.
- Bundle size and build time. Transpiling dependencies and pulling in core-js polyfills grows your JS and slows your pipeline.
useBuiltIns: 'usage'limits the damage, but it won't erase it. - Doubled CSS. Fallback declarations and
@supportsblocks mean more code to maintain, and someone will "clean up" the redundant100vhline six months from now. Leave a comment. - A slower, flakier CI. Three engines plus an emulated device multiplies run time and flake rate. WebKit in Playwright is the same engine family as Safari, not Safari. It misses OS-level quirks like the iOS toolbar and ITP behavior, so keep a short list of real-device checks through BrowserStack or an actual phone in a drawer.
- Support is a business decision. Every extra browser version you keep costs engineering time. Pull the revenue numbers per browser before you argue about dropping iOS 15. Sometimes the right answer is a graceful "please update" screen, not more polyfills.
Before the next freeze#
Pull last month's analytics, sort by browser and version, and find the oldest one still bringing in real revenue. Make that your floor in .browserslistrc, then borrow a device that runs it and walk the checkout yourself. Do it before the sale, not during.