Your JavaScript works perfectly in Chrome. Then a user reports a crash in Firefox. You check Safari and find a third error that doesn't happen anywhere else. Cross-browser JavaScript errors are the silent killers of frontend reliability — they pass all your tests because you tested in one browser, ship to production, and suddenly half your users on older browsers are broken.
The frustrating part is that these errors are usually not your code's fault. They're caused by transpilation gaps, missing polyfills, or features that work differently across browsers. This guide walks you through what causes them, how to spot them in production, and how to prevent them before they hit real users.
Why Some Errors Only Appear in One Browser
Modern JavaScript engines move fast. Chrome supports async/await, optional chaining, and top-level await natively. Firefox keeps up. But Internet Explorer? Edge on Windows 7? Older versions of Safari? They lag years behind — and if even 5% of your users are on those browsers, that's real impact.
The problem starts in your build pipeline. You write modern JavaScript with the latest syntax — arrow functions, destructuring, template literals — then your bundler (Webpack, Vite, esbuild) transpiles it to ES5 or ES2015 so it runs on older targets. But transpilation is lossy. Some features can't be safely downleveled, and some require runtime support that only works if you ship the right polyfills. Skip a polyfill or misconfigure your transpilation target, and an older browser silently fails while Chrome runs fine.
Transpilation Gaps: What Doesn't Downlevel Gracefully
Not everything can be transpiled. Consider Promise:
fetch("/api/data")
.then(res => res.json())
.catch(err => console.error(err));
Your transpiler can convert => to old-style functions, but it can't transpile Promise itself into something older browsers understand. If you don't ship a Promise polyfill, IE 11 doesn't know what Promise is and throws ReferenceError: Promise is not defined.
The same is true for Array.from(), Object.assign(), Array.prototype.includes(), async/await, and dozens of other APIs. Transpilers change syntax (=>, destructuring, class), but they can't invent missing runtime features.
More insidious are bugs in the transpilation itself. Suppose you write:
const [x, y] = data;
A transpiler might turn this into something like:
var _slicedToArray = function(arr) {
return Array.from(arr).slice(0, 2);
};
var _data = data, _ref = _slicedToArray(_data), x = _ref[0], y = _ref[1];
In that snippet, if data is neither an array nor iterable, Array.from() can fail in ways the original destructuring wouldn't. Different transpilers handle this differently — Babel, TypeScript, and swc all have their quirks — and a minor version bump can change how your code behaves on a specific browser.
Check your build target. If you're targeting ES2015 but your users include IE 11, you've already shipped syntax those browsers can't parse. The entire bundle fails before your code even runs.
Polyfills: The Double-Edged Sword
Polyfills fill gaps by providing JavaScript implementations of missing features. The most common example is a Promise polyfill for IE 11:
if (typeof Promise === 'undefined') {
window.Promise = require('promise-polyfill');
}
That works. But polyfills have edge cases. A polyfill for Array.prototype.flat() can't match the native version's performance, and it might behave differently with sparse arrays or deeply nested structures. A polyfill for fetch works for simple cases but might not handle all the fetch API's options correctly.
The bigger risk: shipping the wrong polyfill or the wrong version. If your polyfill bundle is stale, it might not cover all the browsers you're targeting. If you ship too many polyfills, you bloat your JavaScript bundle — every millisecond counts for users on slow 4G. And if you ship polyfills to browsers that don't need them (like Chrome 90), you've wasted bytes and execution time.
This is why tools like @babel/preset-env are so valuable — they analyze your code and your target browsers, then ship only the polyfills you actually need.
Feature Detection and Fallbacks
The most robust pattern is feature detection: test for the feature at runtime and fall back gracefully if it's missing.
if ('IntersectionObserver' in window) {
observer = new IntersectionObserver(callback);
observer.observe(element);
} else {
// Fallback for IE 11: just scroll-listen or use a simpler approach
element.addEventListener('scroll', handleScroll);
}
This works because you're checking for existence, not browser version. If the feature exists, use it. If not, degrade gracefully. This is much safer than trying to polyfill everything.
The catch: feature detection requires thought. It's easy to assume a feature exists, test for it carelessly, or misunderstand what you're checking. And some features can't gracefully degrade — if you need async/await, there's no elegant fallback to synchronous code.
Common Cross-Browser Pitfalls
A few patterns repeatedly cause cross-browser errors:
Object.assign()on browsers without the polyfill. You spread or clone objects, and it silently fails on IE 11.Array.prototype.findIndex()orfind()on missing polyfills. These look like built-ins but aren't in older engines.String.prototype.padStart()andpadEnd().Modern and easy to forget aren't universal.- DOM APIs that changed.
element.classList.add()works everywhere now, but older code usingelement.className +=can break if your build tooling assumes ES5 everywhere. Symbol,Proxy, andReflect. Powerful ES2015 features with no polyfill. Code using them fails entirely on old browsers.
Debugging Cross-Browser JavaScript Errors in Production
The challenge is that cross-browser errors rarely surface in your own testing. You test on your machine (probably Chrome), CI runs tests (usually headless Chrome), and staging is tested with a handful of devices. By the time IE 11 or an older Firefox tries your app, it's in production.
This is where production error tracking becomes essential. When a user on IE 11 hits a ReferenceError: Promise is not defined, your error tracker captures:
- The exact error message and stack trace.
- The browser, OS, and version (from user-agent).
- The breadcrumbs that led to the crash — clicks, navigation, API calls.
- The affected user and how many other users hit the same error.
That's the insight you need: "Oh, every user on IE 11 hits this. We shipped code without a Promise polyfill for that target." Now you can fix it.
import * as Sentry from "@sentry/browser";
Sentry.init({
dsn: "https://<key>@your-lighttrace-host/1",
environment: "production",
tracesSampleRate: 1.0,
// Set the release so you can tie errors to specific builds
release: "web@1.2.3",
});
With a tracker in place, the error lands in your dashboard before your first support ticket, and you see the browser breakdown — 2,000 Firefox events, 500 Safari, 1 IE 11. That's data.
Tag your events with browser and os metadata. Many error trackers extract this automatically from the user-agent, but you can also set it explicitly: Sentry.setContext('browser', { name: 'IE', version: '11' }). Now you can filter and group by browser directly.
Prevention: Build for Browsers You Ship To
The simplest solution is deciding upfront which browsers you must support, then configuring your build for them.
For Vite with @babel/preset-env:
{
"browserslist": ["last 2 versions", "not dead", "not ie 11"]
}
For webpack, configure your transpilation target:
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [['@babel/preset-env', { targets: "> 1%, not dead" }]]
}
}
}
]
}
These configurations tell your build system: "I'm shipping to the last 2 browser versions and anything with >1% market share. Transpile and polyfill for that, not for IE 11." This keeps your bundle lean and your errors minimal.
If you do need to support older browsers, make it explicit and be rigorous. Use a polyfill service like polyfill.io to ship only what that browser needs. Test on real devices or use BrowserStack. Include source maps so errors are readable. Most importantly, monitor production errors by browser so you catch gaps before they spiral.
The goal is knowing, not guessing. Define your browser support matrix, build for it, test across it, and monitor it. When a cross-browser error does slip through, your error tracker surfaces it instantly — and the next fix is a five-minute patch instead of a three-day mystery.
Start tracking errors in minutes
Catch cross-browser JavaScript errors the moment they hit production — get source-mapped stack traces by browser in LightTrace's dashboard.
For a deeper look at how error tracking works and how to read stack traces to find the root cause, see those guides. And for the most common JavaScript errors — like the ones that hit all browsers — check out cannot read properties of undefined.