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:
In Web Browsers: The browser emits a global
unhandledrejectionevent on thewindowobject. If this event is not intercepted and prevented viaevent.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 });In Node.js: Node.js emits an
unhandledRejectionevent on theprocessobject. 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:
- Browsers: Emit the
rejectionhandledevent on thewindowobject. - Node.js: Emits the
rejectionHandledevent on theprocessobject.
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.