DEV Community

Cover image for Stop Losing Errors to the Void: Handling Unhandled Promise Rejections Globally in JavaScript, Angular, and React
Rajat
Rajat

Posted on

Stop Losing Errors to the Void: Handling Unhandled Promise Rejections Globally in JavaScript, Angular, and React

A practical, copy-paste guide to catching the promise rejections your app is currently swallowing without you knowing it

Have you ever had a user tell you "the button just didn't do anything" — and when you checked the console, there was a red error sitting there that nobody ever saw? That's usually an unhandled promise rejection, quietly failing in the background while your app pretends everything is fine.

By the end of this article, you'll know how to:

  • Catch every unhandled promise rejection in a browser app, globally, in one place
  • Understand exactly what triggers unhandledrejection versus a regular try/catch
  • Wire this into an Angular app using the new control flow syntax and signals
  • Wire this into a React app using a clean custom hook
  • Write unit tests for both so this doesn't silently break later
  • Avoid the handful of mistakes that make this "solution" useless in production

If you've ever shipped a feature, felt good about your error handling, and then found out three weeks later that a whole class of failures was never being logged anywhere — this one's for you.

If this kind of "here's the gap in your error handling" deep-dive is useful, go ahead and hit follow — I write one of these every week, and the next one is about a related blind spot in error boundaries.

Before we dive into the examples, a quick note: the code snippets here are meant purely for understanding the concept. Some syntax may reflect patterns from earlier Angular or React versions by the time you're reading this. Always check the official docs for the current API and syntax.

What "Unhandled Promise Rejection" Actually Means

A promise rejection is "unhandled" when a promise fails and nothing in the chain has a .catch() or a try/catch around an await to deal with it. JavaScript doesn't throw immediately — it waits until the current microtask queue drains, and if the rejection still has no handler attached, the runtime fires an unhandledrejection event on the global object.

That's the key detail most people miss: it's not the same mechanism as a thrown synchronous error, and window.onerror will not catch it. These are two separate reporting channels, and a lot of "global error handling" setups only wire up one of them.

Here's the plain vanilla JS version, no framework involved:

window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled promise rejection:', event.reason);

  // Optional: stop the browser from also logging its own default message
  event.preventDefault();

  // This is where you'd send it to Sentry, LogRocket, your own logging endpoint, etc.
});
Enter fullscreen mode Exit fullscreen mode

You can also assign it as a property instead of an event listener:

window.onunhandledrejection = (event) => {
  console.error('Unhandled promise rejection:', event.reason);
};
Enter fullscreen mode Exit fullscreen mode

The event-listener version is generally the better choice, because onunhandledrejection gets overwritten if two parts of your codebase both try to set it. addEventListener lets multiple listeners coexist.

There's also a companion event worth knowing: rejectionhandled. It fires if a promise was initially unhandled but later got a .catch() attached (common with async timing quirks). If you're building serious error tracking, you'll want to listen for both so you don't log false positives.

Wiring This Into an Angular App

In Angular, zone.js patches Promise under the hood, which means unhandled rejections inside the zone still bubble up to the same window unhandledrejection event — so the approach above works without modification. The cleanest way to use it is a small injectable service that exposes the last error as a signal, so any component can react to it.

// error-tracking.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ErrorTrackingService {
  readonly lastError = signal<string | null>(null);

  constructor() {
    window.addEventListener('unhandledrejection', this.handleRejection);
  }

  private handleRejection = (event: PromiseRejectionEvent) => {
    const message = event.reason?.message ?? String(event.reason);
    console.error('Unhandled promise rejection:', event.reason);
    this.lastError.set(message);
    event.preventDefault();
  };
}
Enter fullscreen mode Exit fullscreen mode

And a small component that surfaces it, using the new @if control flow and an input() signal for styling flexibility:

// error-banner.component.ts
import { Component, inject, input } from '@angular/core';
import { ErrorTrackingService } from './error-tracking.service';

@Component({
  selector: 'app-error-banner',
  template: `
    @if (errorTracking.lastError(); as error) {
      <div [class]="bannerClass()">
        {{ error }}
      </div>
    }
  `,
})
export class ErrorBannerComponent {
  protected errorTracking = inject(ErrorTrackingService);
  bannerClass = input('error-banner');
}
Enter fullscreen mode Exit fullscreen mode

Because ErrorTrackingService is providedIn: 'root', its constructor runs once, the first time it's injected anywhere — which in practice means as soon as your app bootstraps and something requests it. Drop <app-error-banner /> near the root of your app and you have a working global catch-all.

Unit Testing the Angular Version

The tricky part of testing this is that PromiseRejectionEvent isn't something you can construct with a plain object — you have to build a regular Event and attach a reason property to it, then dispatch it on window:

// error-tracking.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ErrorTrackingService } from './error-tracking.service';

describe('ErrorTrackingService', () => {
  let service: ErrorTrackingService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(ErrorTrackingService);
  });

  it('captures an unhandled promise rejection from window', () => {
    const rejectionEvent = new Event('unhandledrejection') as PromiseRejectionEvent;
    Object.defineProperty(rejectionEvent, 'reason', {
      value: new Error('Something broke'),
    });

    window.dispatchEvent(rejectionEvent);

    expect(service.lastError()).toBe('Something broke');
  });
});
Enter fullscreen mode Exit fullscreen mode

Quick, answerable one for you: have you ever shipped a "silent" promise rejection to production and only found out about it from a confused user report instead of your monitoring tool? Drop the story in the comments — I want to hear how you eventually tracked it down.

Wiring This Into a React App

React doesn't patch promises, so the pattern is simpler: a custom hook that attaches the listener on mount and cleans it up on unmount.

// useUnhandledRejection.js
import { useEffect, useState, useCallback } from 'react';

export function useUnhandledRejection() {
  const [lastError, setLastError] = useState(null);

  const handleRejection = useCallback((event) => {
    const message = event.reason?.message ?? String(event.reason);
    console.error('Unhandled promise rejection:', event.reason);
    setLastError(message);
    event.preventDefault();
  }, []);

  useEffect(() => {
    window.addEventListener('unhandledrejection', handleRejection);
    return () => window.removeEventListener('unhandledrejection', handleRejection);
  }, [handleRejection]);

  return lastError;
}
Enter fullscreen mode Exit fullscreen mode

And a component that uses it:

// ErrorBanner.jsx
import { useUnhandledRejection } from './useUnhandledRejection';

export function ErrorBanner() {
  const lastError = useUnhandledRejection();

  if (!lastError) return null;

  return <div className="error-banner">{lastError}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Mount <ErrorBanner /> once, near the top of your component tree, and every unhandled rejection anywhere in the app surfaces through it.

Unit Testing the React Version

Using React Testing Library, you dispatch the same kind of synthetic event and assert the banner shows up:

// ErrorBanner.test.jsx
import { render, screen, act } from '@testing-library/react';
import { ErrorBanner } from './ErrorBanner';

test('shows the error banner after an unhandled promise rejection', () => {
  render(<ErrorBanner />);

  act(() => {
    const event = new Event('unhandledrejection');
    event.reason = new Error('Something broke');
    window.dispatchEvent(event);
  });

  expect(screen.getByText('Something broke')).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

One gotcha worth flagging here: if you're running tests under jsdom, its support for a real PromiseRejectionEvent constructor is limited, which is why both examples above build a plain Event and manually attach reason rather than trying to construct the browser-native event class directly.

Bonus Tips

A few things that tend to separate a "works in the demo" version of this from one that actually holds up in production:

  • Call event.preventDefault() only after you've logged the error somewhere durable — it suppresses the browser's own default console warning, which is convenient once you have your own logging in place, but a footgun if you add it before you're actually capturing anything.
  • Pair unhandledrejection with window.onerror (or a window.addEventListener('error', ...) listener) so you're covering both synchronous throws and rejected promises — they are genuinely separate mechanisms.
  • Listen for rejectionhandled too if you're building anything resembling real error monitoring, so a late .catch() doesn't get logged as a permanent failure.
  • If any part of your stack runs in Node (an SSR layer, an API route, a script), remember the API there is different: process.on('unhandledRejection', handler), not window.
  • Route the captured error into whatever tool you already use for monitoring (Sentry, LogRocket, your own backend) rather than leaving it in console.error — the whole point of this pattern is that a developer isn't watching the console when it happens.

Recap

Unhandled promise rejections fail silently by design, and neither Angular's zone patching nor React's rendering model catches them for you automatically — you have to opt in with a global window listener. Once that listener is in place, wrapping it in a service (Angular) or a hook (React) makes it reusable, testable, and easy to hook into whatever logging or monitoring tool you already rely on.

Did this approach match how you're already solving it, or do you have a different take? Drop a comment — I genuinely read every single one.


Found this helpful?
If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference.

Want more tips like this?
I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one.

Let's connect — find me on LinkedIn or GitHub and let's keep the conversation going.


Follow Me for More Angular & Frontend Goodness:

I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.

  • 💼 LinkedIn — Let’s connect professionally
  • 🎥 Threads — Short-form frontend insights
  • 🐦 X (Twitter) — Developer banter + code snippets
  • 👥 BlueSky — Stay up to date on frontend trends
  • 🌟 GitHub Projects — Explore code in action
  • 🌐 Website — Everything in one place
  • 📚 Medium Blog — Long-form content and deep-dives
  • 💬 Dev Blog — Free Long-form content and deep-dives
  • ✉️ Substack — Weekly frontend stories & curated resources
  • 🧩 Portfolio — Projects, talks, and recognitions
  • ✍️ Hashnode — Developer blog posts & tech discussions
  • ✍️ Reddit — Developer blog posts & tech discussions

Top comments (0)