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 🔥!