In a world controlled by the event loop, setTimeout
is the king of execution control. It allows you to schedule function calls to be executed later. However, it can’t do one thing - suspend the execution for a certain amount of time. Think of an equivalent to sleep(ms)
from different languages.
Let us now explore how we can implement it.
Promisifying setTimeout
In order to make setTimeout
behave similar to sleep
we first need to promisify it. For that we will use the Promise
constructor.
A short recap. new Promise((resolve, reject) => {})
accepts two arguments: a resolve
function that we need to call when the promise resolves successfully (we can also pass the resolution value); and a reject
callback that is called if the promise is rejected (we can also pass the rejection error).
Let’s look at a promisified setTimeout()
:
function setTimeoutPromise<T = void>(
cb: () => T,
ms: number
): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => resolve(cb()), ms);
});
}
We then can execute it like this:
setTimeoutPromise(() => "hello", 1000).then(console.log)
Voilà! And if we remove the callback, we can get a basic sleep
function
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
Which can be used as promise chain with .then()
or as await
-able inside an async
function.
We now have a basic sleep(ms)
function. I could end the article here, but setTimeout
has some other cool functionality like the ability to be canceled. And so we can implement a cancelable sleep!
Imagine the following scenario. You want to do some async call to… let’s say an external API. But if the response takes too long, you want to return some known value. This is easily achievable with our setTimeoutPromise
function.
const TIMEOUT = 1500;
// ....
const result = await Promise.any([
getValueFromAPI(),
setTimeoutPromise(() => KNOWN_VALUE, TIMEOUT)
]);
// do something with value
Now, we will either get the value from the external API or if it takes too much time (over 1.5s in this example), we will return a hard-coded value.
Ideally, after we’ve got the value, i.e. Promise.any
was resolved, we would like to cancel the calls to the API and as well as the timeout because we don’t want to have unneeded things in our event loop. Most API calls can be canceled using AbortController
(more on that later), but what about the setTimeout()
?
Cancelable setTimeout
Not everyone knows, but setTimeout
actually returns a value.
The returned timeoutID
is a positive integer value which identifies the timer created by the call to setTimeout()
. This value can be passed to clearTimeout()
to cancel the timeout.
— MDN#setTimeout
So we get a unique timeoutID
that can be used to cancel the timeout. It’s all nice and easy when the code is synchronous:
const timeoutId = setTimeout(doSomeWorkLater, 1500);
// ... some more code
clearTimeout(timeoutId);
How can we cancel a promiseable setTimeout
? Well, our setTimeoutPromise
function, instead of returning a promise function, can return an object that will contain two keys: the promise itself and a function that we can call to cancel the timeout, like this:
interface ReturnValue<T> {
timeout: Promise<T>;
cancel: () => void;
}
function setTimeoutPromise<T = void>(
cb: () => T,
ms: number
): ReturnValue<T> {
let timeoutId: number;
const timeout = new Promise((resolve) => {
timeoutId = setTimeout(() => resolve(cb()), ms);
});
return {
timeout,
cancel: () => timeoutId && clearTimeout(timeoutId)
};
}
Our then previous example turns into something like this:
const TIMEOUT = 1500;
// ....
const {timeout, cancel} = setTimeoutPromise(() => KNOWN_VALUE, TIMEOUT);
const result = await Promise.any([
getValueFromAPI(),
timeout
]);
cancel();
// do something with value
But what about AbortController
?
I briefly mentioned AbortController
, but for those who are not familiar with it, it’s a mechanism to abort web requests. It became a standard way to abort requests made with the request
library and was adopted by axios
as well.
In a nutshell, AbortController
consists of two parts: the controller itself and a signal known as AbortSignal
. The signal is given to the abortable targets while the controller remains in the hands of the one who wishes to abort the request.
const controller = new AbortController();
const signal = controller.signal;
doSomeAsyncWork({signal});
controller.abort();
It would be nice to rewrite our setTimeoutPromise
to use the AbortController
instead of the inconvenient cancel
method we need to deal with.
Set timeout but with AbortController
The AbortController
itself exposes nothing except for the signal
and the abort
method. Remember that we’ve said it’s just a tool to signal for abortion. Therefore we need to look at the signal itself, namely the AbortSignal
.
If we look at the AbortSignal we can see it has two read-only properties: a boolean aborted
to indicate if the signal was aborted, and reason
which can be any value to indicate the reason the signal was aborted (it’s taken from the first argument passed to controller.abort(reason)
). It also has a method throwIfAborted()
which throws the reason
. I suspect it’s just a shorthand for
function throwIfAborted() {
if(this.aborted) {
throw this.reason;
}
}
None of them are helpful for what we need. Luckily for us, AbortSignal
is also an EventTarget
which means it can listen to events. One specific event that we are interested in - is the abort
event. And with the help of addEventListener()
method provided by the EventTarget
- we can actually implement our setTimeoutPromise
!
First, we need to make sure our function is able to accept the AbortSignal
. The usual convention with AbortSignal
is not to pass it as a standalone argument but as part of options
.
Let’s define it
interface Options {
signal?: AbortSignal;
}
function setTimeoutPromise<T = void>(
cb: () => T,
ms: number,
{ signal }: Options = {}
): Promise<T> {
// logic here
}
We still need to return a promise. Also, since it is possible to pass an already aborted signal, we need to have some edge case checking here
return new Promise((resolve, reject) => {
if(signal?.aborted) {
return reject(signal?.reason || new Error('Aborted'))
}
});
However, if the signal was not aborted, we need to create the timeout and capture its id
const timeoutId = setTimeout(() => resolve(cb()), ms);
Now, what’s left is to subscribe to the abort
event of our signal, clear the timeout and reject the promise
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(signal?.reason || new Error('Aborted'));
});
The final result looks like this
interface Options {
signal?: AbortSignal;
}
function setTimeoutPromise<T = void>(
cb: () => T,
ms: number,
{ signal }: Options = {}
): Promise<T> {
return new Promise((resolve, reject) => {
if(signal?.aborted) {
return reject(signal?.reason || new Error('Aborted'))
}
const timeoutId = setTimeout(() => resolve(cb()), ms);
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(signal?.reason || new Error('Aborted'));
});
});
}
We can rewrite our example from earlier like this:
const TIMEOUT = 1500;
// ....
const controller = new AbortController();
const result = await Promise.any([
getValueFromAPI({signal: controller.signal}),
setTimeoutPromise(() => KNOWN_VALUE, TIMEOUT, {signal: controller.signal})
]);
controller.abort();
// do something with value
Hence, no matter what promise resolves first, after we get the value, we can abort the execution of all the remaining promises.
And so, we’ve created our very own, promisified, cancelable version of setTimeout
.
But… You don’t need all this
NodeJS v16 introduced Timer Promises API which are accessible under the timers/promises
module. So you can get a promisified version of all the different timers such as setTimeout
, setImmediate
and setInterval
:
import { setTimeout } from 'timers/promises';
const val = await setTimeout(1000, 'hello world');
console.log(val); // prints 'hello world' after 1s
And they even accept signal
as the last argument to control their cancellation.
Outro
You might think “well if it’s available natively in NodeJS, why do I need to implement this myself?”. And you are right - you should not. If you need timers with promises, use them from timers/promises
module.
But, I’m a big believer in learning through understanding how things work. Most of the knowledge I’ve acquired is because I was curious to know how things work: how operating system kernel works; how IRC protocol works; how instant messengers work; how a compiler works; etc.
When you take an existing, working solution, and try to reimplement it - you learn a lot about why and how decisions were made. And this makes you a better developer.
This is the first article in a series of Understanding Implementations, where I’m going to uncover how things are implemented, in order to help you become better developers. If you have suggestions on what other topics I should cover in this series, feel free to drop me an Email. Until next time!