Actually, I should clarify. For years, I trusted my test suite implicitly. If the CI pipeline turned green, I deployed. I slept like a baby. Then, last Tuesday around 2 PM, I got a frantic Slack message from support. Users couldn’t update their profile settings. “Nothing happens when I click save,” they said.
I checked the build. Green. I checked the integration tests. All passing. I opened the production site, opened the DevTools, clicked “Save,” and there it was. A massive wall of red text.
TypeError: Cannot read properties of undefined (reading 'id').
My UI tests were clicking the button, seeing the “Loading” spinner, and assuming everything was fine because the UI didn’t crash visually. But underneath, the JavaScript engine was screaming into the void. This happens way more than we like to admit. And we often treat the browser console like a trash can that gets emptied every time we refresh the page.
The “Silent” Error Problem
JavaScript is incredibly forgiving. Too forgiving. You can throw an error in an event listener, and unless you have global error boundaries set up perfectly, the page just… stays there. The button creates a ripple effect, the user waits, and nothing happens.
Here is the classic scenario I ran into with a recent React 19 project. We had an async function handling form submission. It looked solid on paper.
async function handleSave(userData) {
try {
setLoading(true);
const response = await api.updateUser(userData);
// If api.updateUser throws 500, we catch it.
// But what if the response structure changed?
updateLocalState(response.data.user);
showToast("Success!");
} catch (error) {
console.error("Save failed", error);
// We logged it, but did we tell the user?
// Sometimes, no. The spinner just stops.
} finally {
setLoading(false);
}
}
If response.data is undefined because the backend team changed the schema without telling the frontend team (classic), this throws. The catch block logs it to the console. The finally block runs, removing the loading spinner. To the automated test—and the user—it looks like the action completed. The spinner went away. No crash. But the data wasn’t saved.
Async/Await: The Black Hole
Asynchronous JavaScript is where error handling goes to die if you aren’t careful. I recently debugged a race condition in a Node 24.1.0 service where unhandled promise rejections were causing memory leaks, but not crashing the process immediately due to a legacy flag.
When you chain promises or mix async/await, it’s easy to lose the error context. Look at this fetch wrapper I found in our legacy code:
// The "I hope this works" wrapper
function getData(url) {
return fetch(url)
.then(res => res.json())
.catch(err => console.log(err)); // The silent killer
}
async function init() {
const data = await getData('/api/settings');
// If fetch failed, data is undefined because of the catch above returning void
// This line throws "Cannot read properties of undefined"
document.getElementById('username').innerText = data.username;
}
The catch inside getData swallows the network error and returns undefined. The calling function init proceeds as if nothing happened, then explodes when it tries to access properties on undefined.
The DOM is a Hostile Environment
It’s not just logic errors. DOM manipulation is fraught with peril, especially when third-party scripts (ads, analytics, chat widgets) start injecting their own garbage into your clean markup.
I was working on a dashboard last month where a specific chart library would throw a generic error if the container div had zero width (which happened during tab switching). This error broke the execution context for the rest of the script block.
// If this selector fails, everything below it stops executing
const container = document.querySelector('#chart-view');
// Unsafe access
const ctx = container.getContext('2d');
// This code never runs if the line above throws
initializeOtherWidgets();
If your automated test suite just checks “Does the page load?” or “Is the header visible?”, it will pass. Meanwhile, your widgets aren’t initializing.
The Fix: Fail on Console Errors
Here is the strategy that saved my bacon. Stop treating console errors as “info.” Treat them as test failures.
If you are using Playwright (I’m currently on version 1.52), you can hook into the console messages. I set this up in our E2E suite, and the first run failed 45 tests that were previously “passing.” It was brutal, but honest.
// playwright.config.ts or inside your test setup
test.beforeEach(async ({ page }) => {
const errors = [];
// Listen for console errors
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
// Listen for unhandled exceptions
page.on('pageerror', exception => {
errors.push(exception.message);
});
// Fail the test if we collected any errors
test.afterEach(() => {
if (errors.length > 0) {
throw new Error(Console errors detected:\n${errors.join('\n')});
}
});
});
This snippet changes everything. Suddenly, that 404 on a missing favicon fails the build. That React key warning? Fails the build. That third-party tracker that’s misconfigured? Fails the build.
Global Handlers for Production
You can’t run Playwright in production (well, you shouldn’t). For real users, you need a safety net that catches what slips through. I always ensure these two listeners are the very first thing that runs in the <head>, before any framework loads.
window.addEventListener('error', (event) => {
// Catches synchronous errors and some DOM errors
console.error('Global capture:', event.message);
// Send to your logging service (Sentry, Datadog, etc.)
logToService({
type: 'uncaught_exception',
message: event.message,
stack: event.error?.stack
});
});
window.addEventListener('unhandledrejection', (event) => {
// Catches those silent async Promise failures
console.error('Unhandled Promise:', event.reason);
logToService({
type: 'unhandled_promise',
message: event.reason?.message || String(event.reason)
});
});
I learned the hard way that window.onerror doesn’t catch Promise rejections. We had a payment flow failing silently for three days in 2024 because of a rejected Promise in a validation library. The user just saw the “Pay” button stay disabled. Since adding the unhandledrejection listener, we catch those instantly.
Why This Matters Now
Frontend applications in 2026 are basically operating systems running inside a browser tab. The complexity is massive. We rely on dozens of micro-services, third-party APIs, and heavy frameworks.
Ignoring JavaScript errors because “the UI looks fine” is like driving a car with the check engine light on because the radio still works. Eventually, the engine seizes. Don’t wait for a user to tell you the “Save” button is broken. Let your CI tell you the console is bleeding red.
