JS protip: Delaying with setTimeout(), but using await!
By Christophe Porteneuve • Published on 8 February 2023 • 2 min

Cette page est également disponible en français.

This is 2023, promises and async / await are everywhere. And yet, we still use good ol’ callback-based setTimeout() and setInterval()… Ah well, these are old-timers (pun intended), so I guess we’re out of luck.

Are we though?

A long-awaited ugprade

Let’s start with the backend side of things. Node.js has always offered the browser’s setTimeout() and setInterval() APIs: without them (or console) adoption would likely have been non-existent! 😅

These old APIs are, as you’d expect, callback-based; but Node 15 introduced a promise-based variant:

import { setTimeout } from 'node:timers/promises'

console.log(new Date().toLocaleTimeString('en')) // => 5:29:19 PM
await setTimeout(1000)
console.log(new Date().toLocaleTimeString('en')) // => 5:29:20 PM

Smokin’! 🤩

When I use it like that, I tend to rename it:

import { setTimeout as sleep } from 'node:timers/promises'

await sleep(1000)

Unlike the sleep of old blocking runtimes, this doesn’t block the thread: these are promises, after all! await suspends, it doesn’t block.

Not just setTimeout(), either…

Anything from timers is covered, including setInterval() and setImmediate().

As for intervals, you probably wonder how things go, as it’s a recurring thing? Well, it returns an async iterable (that so happens to always produce the same value, which you may provide using the 2nd argument), something you can consume for instance with a for…await loop:

import { setInterval } from 'node:timers/promises'

let count = 5
console.log(new Date().toLocaleTimeString('en')) // => 5:29:29 PM

for await (const _ of setInterval(1000)) {
if (count-- === 0) break

console.log(new Date().toLocaleTimeString('en'))
// => 5:29:30 PM, 5:29:31 PM, 5:29:32 PM, 5:29:33 PM, 5:29:34 PM
}

console.log(new Date().toLocaleTimeString('en')) // => 5:29:35 PM

In practice, the clearInterval() is achieved by simply exiting the loop (here with a break), but what if you want to cancel the timer from another code location? Well, it uses the same decoupled cancellation mechanism you get for anything promise-based: AbortController and AbortSignal.

import { setInterval } from 'node:timers/promises'

const ac = new AbortController()

for await (const _ of setInterval(1000, { signal: ac.signal })) {
// …
}

// Some code somewhere may hold a reference to the controller and
// just run this when it sees fit:
ac.abort()

You can find the same kind of variant for event listeners(e.g. await once(stream, 'close') with node:events), filesystem access (e.g. await readdir(path) with node:fs/promises), or even readable streams (both classic Node streams or the ReadableStream Web API), the latter being async iterables aware of signals.

In short, await is the way! 😎

Where can I use that?

As for timers, since Node 15. Filesystem, readable streams and events had this since Node 10 (although importing from node:fs/promises only stabilized with Node 14, and signal awareness came with Node 15).

What about browsers?

Only “recent” APIs are promise-based, but you can easily wrap setTimeout for promises:

function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve, delay))
}

If you really want to go all the way and handle signals, you can do that too (they’ve been supported on modern browsers for many years):

function sleep(delay, { signal } = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, delay)

signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
reject(signal.reason)
},
{ once: true }
)
})
}

(As for setInterval, it’s not much harder, but I’ll leave it, as they say, as an exercise to the reader 😉).

Protips galore!

We got tons of articles, with a lot more to come. Also check out our kick-ass training courses 🔥!