How long can promise stay unhandled without triggering "unhandledrejection" event?

86 Views Asked by At

I am wondering when exactly do browsers check for unhandled promises? I thought that check is performed in the end of event loop tick. But simple experiment shows the opposite.

If I register two handlers for unhandledrejection and rejectionhandled events:

window.addEventListener('unhandledrejection', function(event) {
    console.log('UNHANDLED!'); 
    event.preventDefault();
});

window.addEventListener('rejectionhandled', function(event) {
    console.log('HANDLED!'); 
    event.preventDefault();
});

And then run such code:

let promise = Promise.reject(new Error("Promise Failed!"));
// Plan handling in next tick, 
//  in my understanding it SHOULD happen AFTER the unhandledrejection will be triggered
setTimeout(() => promise.catch(err => console.log('CAUGHT!')), 0);

I expect events unhandledrejection and rejectionhandled to be triggered. But they are not triggered (there is no 'UNHANDLED!' and 'HANDLED!' messages in console).

The most strange is that if I change 0 to 1 ms in setTimeout, the events are triggered:

let promise = Promise.reject(new Error("Promise Failed!"));
// Plan handling after 1 ms, 
// REALLY happens AFTER the unhandledrejection is triggered
setTimeout(() => promise.catch(err => console.log('CAUGHT!')), 1);

I could not find an explanation in HTML5 spec.

I tried to run provided snippets in several browsers' consoles (Chrome, Firefox, Safari), and expected unhandledrejection and rejectionhandled events to be triggered. But they did not.

Could someone explain such behaviour, or provide some useful links?

1

There are 1 best solutions below

2
richytong On

To understand the behavior of the code you provided, you should first understand the following concepts:

  • Event Loop
  • Call Stack
  • Callback Queue

This excerpt from Javascript Runtime: JS Engine, Event Loop, Call Stack, Execution Contexts, Heap, and Queues is helpful to understand the above concepts.

After an asynchronous event occurs (a timer expires or a network request completes), the associated callback function is placed into the Callback Queue. When the Call Stack becomes empty (meaning all synchronous tasks are complete), the Event Loop checks the Callback Queue. If there are any callback functions in the Callback Queue, the Event Loop takes the first one and passes it to the Call Stack for execution.

Next, you should understand how JavaScript Timers and setTimeout work. This answer should help you understand Timers and setTimeout.

It is the environment that puts these callbacks [from setTimeout] onto the queue, to be executed by the single threaded JavaScript engine.

Now if you understand all that, here is a walkthrough of the code you provided. I have named some of the functions for clarity.

First, we add the event listeners. They will act as you described, but here is a reference for the Promise rejection events unhandledrejection and rejectionhandled.

unhandledrejection Sent when a promise is rejected but there is no rejection handler available.

rejectionhandled Sent when a handler is attached to a rejected promise that has already caused an unhandledrejection event.

window.addEventListener('unhandledrejection', function unhandledrejectionHandler(event) {
    console.log('UNHANDLED!'); 
    event.preventDefault();
});

window.addEventListener('rejectionhandled', function rejectionhandledHandler(event) {
    console.log('HANDLED!'); 
    event.preventDefault();
});

Now I will take you through the two cases when the delay is 0 and when the delay is 1.

let promise = Promise.reject(new Error("Promise Failed!"));

let promiseCatchingCb = () => promise.catch(err => console.log('CAUGHT!'))

// Timer executes promiseCatchingCb immediately, adding it to the top of the call stack
setTimeout(promiseCatchingCb, 0);
  1. Promise rejected
  2. promiseCatchingCb is immediately added to callback queue and executed
  3. window unhandledrejection event listener never triggered because rejected promise is already handled
let promise = Promise.reject(new Error("Promise Failed!"));

let promiseCatchingCb = () => promise.catch(err => console.log('CAUGHT!'))

// Timer executes promiseCatchingCb immediately, adding it to the top of the call stack
setTimeout(promiseCatchingCb, 1);
  1. Promise rejected
  2. promiseCatchingCb is not immediately added to the callback queue because timer is still ongoing (1ms)
  3. unhandledrejection event is fired and unhandledrejectionHandler is added to the callback queue and executed
  4. promiseCatchingCb is added to the callback queue and executed
  5. rejectionhandled event is fired and rejectionhandledHandler is added to the callback queue and executed

In short, when you set the delay of setTimeout to be 0, there isn't any time for the unhandledrejection event to be fired. This isn't the same as synchronous code executing on the callstack happening before asynchronous code executing on the callback queue because the different functions in question are all sharing the callback queue.