Why doesn’t Node.js exit after Promise.race is finished?

In Node.js, I use Promise.race to timeout and cancel requests made by the Request-Promise library. My Promise.race implementation seems to block the program.

Promise.race does resolve and return but after that, the program never exits.

Again, I want to emphasize that the await indeed completes and logs the response but the program never exits.

const rp = require('request-promise');

/**
 * @param {rp.RequestPromise} request 
 * @param {Number} ms 
 * @returns {Error}
 */
const delay = (request, ms) => new Promise((resolve, reject) => setTimeout(() => {
    request.cancel();
    return reject(new Error("Timeout"))
}, ms))

async function main() {
    try {
        const options = {
            method: 'GET',
            url: "https://api.ipify.org/?format=json",
        };

        const request = rp(options);
        const response = await Promise.race([request, delay(request, 500000)]);

        console.log(response)

    } catch (e) {
        console.log(e)
    }
}

main()

Does anyone know what causes this?

Answer

Neither is Promise.race(), nor await the source of the issue. It is setTimeout.

Actually, your code does work. One small thing, that it keeps hanging when it is completed. Not forever, only until your timer expires. For some 500 seconds.

Node.js works by tracking all the possible inputs to your program and exits only when there aren’t any (or, of course, if exited programmatically via process.exit() or manually by sending an interrupt or kill signal to it). However, your setTimeout is an input; although its completion is a no-op, Node.js doesn’t know that, so it keeps the process alive.

To resolve the issue, you can tell Node.js, that the setTimeout is not important and that your program may exit regardless of it by unreffing it:

const rp = require('request-promise');

/**
 * @param {rp.RequestPromise} request 
 * @param {Number} ms 
 * @returns {Error}
 */
const delay = (request, ms) => new Promise((resolve, reject) => 
    setTimeout(() => {
        request.cancel();
        return reject(new Error("Timeout"))
    }, ms)
    //Call unref on the interval ID object that setTimeout returned
    //Note that this is Node.js only.
    .unref()
)

async function main() {
    try {
        const options = {
            method: 'GET',
            url: "https://api.ipify.org/?format=json",
        };

        const request = rp(options);
        const response = await Promise.race([request, delay(request, 500000)]);

        console.log(response)

    } catch (e) {
        console.log(e)
    }
}

main()

That way, the process will be kept alive only until the request completes; if the timer fires faster, it will cancel the request and therefore exit the program, but if your request finishes first, it may just exit without waiting for the timer.

However, note that such setTimeout calls will accumulate (this code doesn’t cancel the timeout if there’s something else in the event loop that keeps the process alive), which can negatively affect performance.

So, if your code does this a lot (or, as in your case, timeouts are long), then you should find a way to cancel your timeouts programmatically when your requests complete. For example:

const rp = require('request-promise');

/**
 * @param {rp.RequestPromise} request 
 * @param {Number} ms 
 * @returns {Error}
 */
const delay = (request, ms) =>
  new Promise((resolve, reject) => {
    const id = setTimeout(() => {
      request.cancel();
      return reject(new Error("Timeout"))
    }, ms)
    
    request
      //This will handle the promise (and makes possible unhandled-rejection warnings away) to avoid breaking on errors, but you should still handle this promise!
      .catch(() => {})
      .then(() => clearTimeout(id))
  }
)

async function main() {
  try {
    const options = {
      method: 'GET',
      url: "https://api.ipify.org/?format=json",
    };

    const request = rp(options);
    const response = await Promise.race([request, delay(request, 500000)]);

    console.log(response)

  } catch (e) {
    console.log(e)
  }
}