Implementing Promisable setTimeout

·

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!

Share this:

Published by

Dmitry Kudryavtsev

Dmitry Kudryavtsev

Senior Software Engineer / Tech Lead / Consultant

With more than 14 years of professional experience in tech, Dmitry is a generalist software engineer with a strong passion to writing code and writing about code.


Technical Writing for Software Engineers - Book Cover

Recently, I released a new book called Technical Writing for Software Engineers - A Handbook. It’s a short handbook about how to improve your technical writing.

The book contains my experience and mistakes I made, together with examples of different technical documents you will have to write during your career. If you believe it might help you, consider purchasing it to support my work and this blog.

Get it on Gumroad or Leanpub


From Applicant to Employee - Book Cover

Were you affected by the recent lay-offs in tech? Are you looking for a new workplace? Do you want to get into tech?

Consider getting my and my wife’s recent book From Applicant to Employee - Your blueprint for landing a job in tech. It contains our combined knowledge on the interviewing process in small, and big tech companies. Together with tips and tricks on how to prepare for your interview, befriend your recruiter, and find a good match between you and potential employer.

Get it on Gumroad or LeanPub