Is it safe to use async functions as callbacks in the JavaScript timer setTimeout and setInterval functions?

At Atomist, our open source client libraries are written in TypeScript and run on Node.js, so I end up writing a lot of TypeScript. TypeScript is a nice language but sometimes you end up in the dark corners of JavaScript or the runtime. The other day I needed to call a function that returns a Promise from within the callback function passed to JavaScript's setTimeout function. The callback executed by setTimeout is not expected to return anything, it just ignores the returned value. Since once you enter the promise/async world in JavaScript you cannot escape, I was left to wonder what happens when the setTimeout callback returns a promise?

My gut told me calling an async function from the setTimeout callback was a bad thing. Since the setTimeout machinery ignores the return value of the function, there is no way it was awaiting on it. This means that there will be an unhandled promise. An unhandled promise could mean problems if the function called in the callback takes a long time to complete or throws an error.

Unfortunately, it turns out that searching on phrases like "javascript settimeout async", "node timer async", and "settimeout callback promise" result in discussions of the difference between callback, promise, and async approaches in JavaScript or various JavsScript implementations of the sleep function using setTimeout. Using the magic incantation "javascript call async function from setinterval", I did find a couple relevant links hidden in the additional Stack Overflow findings. Both the answers that suggested a solution using async/await in the setInterval callback was just fine.

Since my searching turned up and answer I didn't expect, I figured it was best to test the behavior. For these tests, I am running Node.js 12.11.1 on macOS. Before we get to the tests, here are a couple helper functions I'll be using.

async function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function bad() {
    throw new Error("bad");
}

The first is the standard "JavaScript sleep function". The second is an async function, so it implicitly returns a promise, that throws an error we can use to test what happens when a thrown error is not handled. We will also use the following code in our file to invoke our main function and try to make sure that the Node.js event loop keeps going and errors are handled.

main()
    .then(() => console.log("main returned"),
          err => console.error("Uncaught exception: " + err.message));

First, let's test what happens when we schedule a timer that calls an async function that throws an error.

async function main() {
    setTimeout(bad, 100);
    await sleep(200);
}

When we run this we see

(node:33489) UnhandledPromiseRejectionWarning: Error: bad
    at Timeout.bad [as _onTimeout] (/Users/dd/SpiderOak Hive/code/node/async-timer.js:9:11)
    at listOnTimeout (internal/timers.js:531:17)
    at processTimers (internal/timers.js:475:7)
(node:33489) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
(node:33489) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
main returned

which seems bad. You may reply, "I've been seeing that deprecation warning from Node.js for years, don't worry about it." That may be true, but it still seems like something you would want to avoid.

A first attempt at addressing the uncaught error issue would be to throw a try/catch block around the whole callback.

async funtion main() {
    setTimeout(async () => {
        try {
            await bad();
        } catch (e) {
            console.warn("caught: " + e.message)
        }
    }, 100);
    await sleep(200);
}

When we run this, we get

caught: bad
main returned

which seems like what we want.

It looks like we have addressed the issue with uncaught errors, but what about calling a long-running function from the callback? Here's a main function that tests that.

async function main() {
    setTimeout(async () => {
        console.log("before");
        await sleep(100);
        console.log("after");
    }, 100);
}

When we run that, we get

main returned
before
after

We see the console.log statements in the callback function are executed after the main function returns, which makes sense given our original misgivings about using async in a timer callback. The setTimeout function returns immediately and the main function returns prior to the execution of the timeout callback. But what is interesting is that even though the main function has returned and our "script" has completed, the Node.js event loop keeps running. Node.js still registers that there is pending work, even though that work is in the form of the dangling promise from the callback that no one is waiting on, no one except the Node.js event loop that is.

It is worth noting that the Node.js process.exit function preempts the event loop, i.e., it terminates the Node.js process without regard for pending async operations. So if we call the main function using something like this:

main()
    .then(() => { console.log("main returned"); process.exit(0); },
          err => { console.error("Uncaught exception: " + err.message); process.exit(1); });

and run the above main function, the output will just be

main returned

with an exit code of zero (0). In other words, the setTimeout callback is not executed. So be careful using process.exit if you care about pending work completing.

Reach out to me on Twitter with any follow up questions or comments -- @ddgenome