JavaScript Event Loop: Unhandled Promise Rejections

This article explains how the JavaScript runtime processes unhandled promise rejections within the event loop mechanism. It covers the role of the microtask queue during promise resolution, how the engine detects promises without error handlers, and the subsequent actions taken by host environments like Node.js and web browsers when a rejected promise is left unhandled.

Microtasks and Promise Rejection

JavaScript handles asynchronous operations using the event loop, which prioritizes tasks using two primary queues: the macrotask queue (e.g., setTimeout, I/O events) and the microtask queue (e.g., Promise callbacks, queueMicrotask).

When a promise is rejected via Promise.reject() or by throwing an error inside an asynchronous function, its rejection callback is scheduled as a microtask. The JavaScript engine executes the current synchronous code on the call stack to completion. Once the call stack is completely empty, the event loop immediately processes all tasks in the microtask queue before moving to the next macrotask or rendering phase.

// Synchronous task
console.log("Start");

// Microtask scheduled
Promise.reject(new Error("Failure without catch"));

// Synchronous task
console.log("End");

The Microtask Checkpoint and Detection

During the execution of the microtask queue—known as a microtask checkpoint—the engine tracks the state of every promise. When a promise transitions to the rejected state, the engine checks whether a rejection handler (such as a .catch() block or a rejection handler in .then(onFulfilled, onRejected)) has been attached.

If an error occurs and no handler is found, the promise is flagged internally as an unhandled rejection. The engine does not immediately throw a global exception; instead, it waits until the end of the current microtask turn to allow any synchronous chaining of .catch() handlers to register.

Host Environment Notification

JavaScript itself (ECMAScript specification) defines promise states, but how unhandled rejections are reported is delegated to the host environment (browsers or Node.js).

Once the microtask checkpoint finishes and a rejected promise remains without a handler:

  1. In Web Browsers: The browser emits a global unhandledrejection event on the window object. If this event is not intercepted and prevented via event.preventDefault(), the browser logs the error to the developer console.

    window.addEventListener("unhandledrejection", (event) => {
      console.error("Unhandled rejection detected:", event.reason);
      event.preventDefault(); // Prevents default console logging
    });
  2. In Node.js: Node.js emits an unhandledRejection event on the process object. In modern versions of Node.js (v15+), unhandled promise rejections terminate the process with a non-zero exit code by default, treating them similarly to uncaught exceptions.

    process.on("unhandledRejection", (reason, promise) => {
      console.error("Unhandled Rejection at:", promise, "reason:", reason);
      // Application specific logging or cleanup
    });

Handling Late Rejections

If a rejection handler is attached asynchronously after the microtask checkpoint has already passed, the runtime triggers a secondary event:

This secondary notification informs the environment that a previously unhandled rejection has finally been caught, allowing monitoring tools and runtimes to update their error tracking states accordingly.