TypeScript Cancellable sleep/delay

eye-catch JavaScript/TypeScript

Many websites explain how to implement sleep in TypeScript/JavaScript. It can be implemented by using Promise and setTimeout. It looks easy.

function sleep(ms: number): Promise<void> {
    return new Promise(resolve => global.setTimeout(resolve, ms));
}

However, don’t you think it’s better if we have more control for the timer?

Haven’t you ever needed to stop the sleep timer when another event is triggered?

Sponsored links

Usage of setTimeout

If you want to do something 1 second later, setTimeout can be used.

global.setTimeout(() => {
    console.log("Do what you want here");
}, 1000);

If you have a function and a preparation step is needed, you implement it like the following.

function doSomething(): void{
    console.log("Preparation step");
    global.setTimeout(() => {
        console.log("Do what you want here");
    }, 1000);
}

It looks ok at first.. but, what if we need to add an additional process with setTimeout?

function doSomething(): void {
    console.log("Preparation step");
    global.setTimeout(() => {
        console.log("Do what you want here");
    }, 1000);

    global.setTimeout(() => {
        console.log("Do something 2");
    }, 2000);
}

If the process is independent of the first one, this code is ok but if we need to trigger the process after the first process is done, we have to implement it in the following way.

function doSomething(): void {
    console.log("Preparation step");
    global.setTimeout(() => {
        console.log("Do what you want here");

        global.setTimeout(() => {
            console.log("Do something 2");
        }, 1000);
    }, 1000);
}

setTimeout in setTimeout. It is similar to callback hell.

Sponsored links

Creating sleep function with Promise

To make the code clearer, we should use async/await keyword there. The goal of the function looks like this below.

async function doSomething2(): Promise<void>  {
    console.log("Preparation step");
    await sleep(1000);
    console.log("Do what you want here");
    await sleep(1000);
    console.log("Do something2");
}

You can also write in this way.

function doSomething3(): Promise<void> {
    console.log("Preparation step");
    return sleep(1000)
        .then(() => {
            console.log("Do what you want here");
            return sleep(1000);
        })
        .then(() => {
            console.log("Do something2");
        })
}

It is much more readable than the one in the previous section, isn’t it?

To use, async/await keyword, sleep function must return Promise. The sleep function is implemented in the following way.

function sleep(ms: number): Promise<void> {
    return new Promise(resolve => global.setTimeout(resolve, ms));
}

Promise has resolve and reject callback but reject is not necessary here because setTimeout doesn’t throw an error with this code. resolve callback is called when the specified millisecond is elapsed.

Check this article, if you want to know more about Promise

Cancellable sleep

We learned how to use setTimeout and impelment sleep function. The next step is to stop the timer somehow.

setTimeout has a return value. The return data type depends on the system but it is NodeJS.Timeout on Node.js for example. We can stop the timer.

The following code set the callback but it won’t be triggered because the timer is cleared by clearTimeout.

const timer = global.setTimeout(() => {
    console.log("timeout----");
}, 1000)
global.clearTimeout(timer);

This can be easily implemented where sleep is needed but the sleep function that we defined above doesn’t have reject callback. It means it’s impossible to know the cancellation reason in the caller side.

Let’s improve this.

interface CancellableSleep {
    promise: Promise<void>;
    cancel(reason?: any): void;
}

function cancellableSleep(ms: number): CancellableSleep {
    let timer: NodeJS.Timeout;
    let rejectPromise: (reason?: unknown) => void;

    const promise = new Promise<void>((resolve, reject) => {
        timer = global.setTimeout(() => resolve(), ms);
        rejectPromise = reject;
    });

    return {
        cancel: (reason?: unknown) => {
            global.clearTimeout(timer);
            rejectPromise(reason || new Error("Timeout cancelled"));
        },
        promise,
    };
}

When cancel function is called, we need to call clearTimeout and then, call reject callback with the canceled reason.

I implemented it in the following way at first, but IntelliSense shows an error.

// Type 'number' is not assignable to type 'Timeout'
timer = global.setTimeout(resolve, ms);

Usage of cancellable sleep

Let’s see an example of the usage.

The following class tries to connect somewhere. When the connection fails, it retries the connection attempt.

class Connector {
    private timeout?: CancellableSleep;
    private count = 0;
    private readonly maxRetryCount = 3;

    public async connect(): Promise<void> {
        while (true) {
            try {
                // try to connect somethere
                console.log(`Connection attempt: ${this.count + 1}`);
                this.count++;

                if (this.count < this.maxRetryCount) {
                    throw new Error("Connection was not established.");
                }
                this.count = 0;
                return Promise.resolve();
            } catch (e) {
                if (e instanceof Error) {
                    console.error(e.message);
                }

                this.timeout = cancellableSleep(1000);
                await this.timeout.promise;
            }
        }
    }
    public stop() {
        this.timeout?.cancel("Connection is no longer needed.");
        this.timeout = undefined;
    }
}

As you can see, cancellableSleep is called in the catch block to retry the connection attempt. It tries to connect 1 second later. It could be 1 minute in production code. There are some cases where we need to stop the timer in order to avoid over retry when the system is interrupted by an event or use interaction.

In this case, a client-side can call the stop method.

The following example stops the timer after 1500 milliseconds.

const connector = new Connector();
connector.connect()
    .then(() => {
        console.log("Connected");
    })
    .catch((reason) => {
        console.error(`Connection failure: ${reason}`)
    });
global.setTimeout(() => connector.stop(), 1500);

// Connection attempt: 1
// Connection was not established.
// Connection attempt: 2
// Connection was not established.
// Connection failure: Connection is no longer needed.

Even if you have many cases to stop the timer for various reasons, error handling can be done in only one place in this way.

Comments

Copied title and URL