Skip to content
aviral gupta

// B4.4 · ~31 min · Beginner

Timers, setImmediate and process.nextTick

After this lesson you can schedule, repeat and cancel timers in Node.js, predict when setImmediate, process.nextTick and promise callbacks run, and decide which timers may keep your program running.

Lesson 4 of 5 in B4 Asynchronous Node

You will be able to

  • Schedule, repeat and cancel timers, also with node:timers/promises
  • Predict the order of setTimeout, setImmediate, process.nextTick and microtasks from the loop's phases
  • Decide what keeps a program running, with unref(), ref() and {ref: false}
  1. Warm-up · Activity 1 of 7

    Warm-up from tech.javascript: in which order does this program print?

    setTimeout(() => console.log('timeout'), 0);
    Promise.resolve().then(() => console.log('promise'));
    console.log('sync');
  2. Predict · Activity 2 of 7

    Predict before you read on. main.js is an ES module, as in all our lessons. In which order does it print?

    Promise.resolve().then(() => console.log('resolve'));
    queueMicrotask(() => console.log('microtask'));
    process.nextTick(() => console.log('nextTick'));
    console.log('sync');
  3. Practice · Activity 3 of 7

    Fill in the method, so that this timer no longer keeps the program running: it prints done and exits at once, without fired.

    const timer = setTimeout(() => console.log('fired'), 300);
    console.log(timer.hasRef());
    timer.____();
    console.log(timer.hasRef());
    console.log('done');
    timer.();
  4. Practice · Activity 4 of 7

    Match each way of scheduling a callback with where Node.js runs it.

  5. Practice · Activity 5 of 7

    Both calls are made inside the callback of readFile, an I/O callback. What does the program print?

    import {readFile} from 'node:fs';
    
    readFile('main.js', () => {
      setTimeout(() => console.log('timeout'), 0);
      setImmediate(() => console.log('immediate'));
    });
  6. Brain teaser · Activity 6 of 7

    Brain teaser. spin schedules itself with process.nextTick until n reaches 100000. The timer has a delay of 0. What does it print?

    let n = 0;
    setTimeout(() => console.log('timer sees', n), 0);
    
    function spin() {
      if (++n < 100000) process.nextTick(spin);
    }
    process.nextTick(spin);
  7. Apply · Activity 7 of 7

    Mini-task. A job takes 250 ms (await sleep(250) from node:timers/promises). While it runs, print still working every 100 ms with setInterval. Add a watchdog: a setTimeout of 5 s that prints too slow, giving up to stderr and calls process.exit(1), but must not keep the program running. Clear the interval in finally. Run node main.js: it should end right after job done.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The queues, the phases, an unref timer and an async interval

Part 1 schedules a nextTick, a microtask and a promise callback from the top level of an ES module. Part 2 schedules all four kinds inside an I/O callback, where their order is fixed. Part 3 starts a one-second timer and unrefs it. Part 4 iterates setInterval from node:timers/promises three times. The program never schedules setTimeout(0) against setImmediate from the main module, whose order is not fixed.

main.js

import {readFile} from 'node:fs';
import {setInterval} from 'node:timers/promises';

// 1. The main module is an ES module: microtasks run before the next tick queue.
process.nextTick(() => console.log('main: nextTick'));
queueMicrotask(() => console.log('main: queueMicrotask'));
Promise.resolve().then(() => console.log('main: promise'));
console.log('main: sync code');

// 2. Inside an I/O callback the order is fixed.
readFile('main.js', () => {
  setTimeout(() => console.log('io: setTimeout 0'), 0);
  setImmediate(() => console.log('io: setImmediate'));
  queueMicrotask(() => console.log('io: queueMicrotask'));
  process.nextTick(() => console.log('io: nextTick'));
  console.log('io: callback');
});

// 3. An unref'd timer does not keep the program alive.
setTimeout(() => console.log('never printed'), 1000).unref();

// 4. timers/promises: setInterval as an async iterator.
let ticks = 0;
for await (const label of setInterval(50, 'tick')) {
  console.log(label, ++ticks);
  if (ticks === 3) break; // break stops the interval
}
console.log('done; nothing keeps the loop alive');

Run it with

node main.js

Output

main: sync code
main: queueMicrotask
main: promise
main: nextTick
io: callback
io: nextTick
io: queueMicrotask
io: setImmediate
io: setTimeout 0
tick 1
tick 2
tick 3
done; nothing keeps the loop alive
  • In the ES module, both microtasks run before main: nextTick. In a .cjs file, nextTick would come first.
  • In the I/O callback, nextTick runs before the microtask: the callback is not a microtask itself.
  • setImmediate runs in the check phase, right after poll; the 0 ms timer waits for the timers phase.
  • The unref timer was still pending at the end, but it did not keep the program alive.
  • break ends the for await loop and stops the interval, so the program can exit.

Exercises

Exercise 1 of 2

Debounce with clearTimeout

debounce(fn, ms) returns a function that waits: each call cancels the call scheduled before it and schedules fn again, ms milliseconds later, with the newest arguments. So quick calls lead to one call of fn, with the last arguments, after a quiet pause of ms. The starter calls fn at once, every time. Use setTimeout and clearTimeout. This part runs in the browser too.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Keep the Timeout in a variable outside the returned function: let timer;

  2. Hint 2

    In the returned function, clearTimeout(timer) first. clearTimeout with undefined does nothing, so the first call is fine.

  3. Hint 3

    Then timer = setTimeout(() => fn(...args), ms); the arrow function keeps the newest args.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Returns a function that calls fn only after ms milliseconds without a new call,
// with the arguments of the last call.
export function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer); // cancels the call scheduled before, if any
    timer = setTimeout(() => fn(...args), ms);
  };
}

// Typing H, He, Hello quickly: only the last text should be saved.
const save = debounce((text) => console.log('saved:', text), 50);
save('H');
save('He');
save('Hello');
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Returns a function that calls fn only after ms milliseconds without a new call,
// with the arguments of the last call.
export function debounce(fn, ms) {
  return (...args) => fn(...args); // calls fn at once, every time
}

// Typing H, He, Hello quickly: only the last text should be saved.
const save = debounce((text) => console.log('saved:', text), 50);
save('H');
save('He');
save('Hello');

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {debounce} from './main.js';

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

test('three quick calls lead to one call, with the last arguments', async () => {
  const calls = [];
  const save = debounce((text) => calls.push(text), 30);
  save('H');
  save('He');
  save('Hello');
  await sleep(90);
  assert.deepEqual(calls, ['Hello'], `fn was called with ${JSON.stringify(calls)}`);
});

test('fn is not called before ms have passed', async () => {
  const calls = [];
  const save = debounce((text) => calls.push(text), 30);
  save('now?');
  assert.equal(calls.length, 0, 'fn was called at once; it should wait');
  await sleep(90);
  assert.equal(calls.length, 1, `fn was called ${calls.length} times after the pause; expected once`);
});

test('calls with a pause between them are all made', async () => {
  const calls = [];
  const save = debounce((text) => calls.push(text), 30);
  save('a');
  await sleep(90);
  save('b');
  await sleep(90);
  assert.deepEqual(calls, ['a', 'b'], `fn was called with ${JSON.stringify(calls)}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

A countdown with an async interval, and a watchdog

countdown(from, ms, log) logs from, from - 1, ... down to 1, one line every ms milliseconds, then 'liftoff'. Use setInterval from node:timers/promises in a for await loop, and break after 1 so that the interval stops. watchdog(ms, onTimeout) returns a timer that calls onTimeout after ms but does not keep the program running. Run node main.js, then node --test.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    for await (const _ of setInterval(ms)) { ... } runs its body once every ms.

  2. Hint 2

    Log String(n), count n down, and break when it reaches 0; after the loop, log 'liftoff'.

  3. Hint 3

    setTimeout(...).unref() returns the same Timeout, so you can return it directly.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

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

// Logs from, from - 1, ..., 1, one line every ms milliseconds, then 'liftoff'.
export async function countdown(from, ms, log = console.log) {
  let n = from;
  for await (const _ of setInterval(ms)) {
    log(String(n));
    n--;
    if (n === 0) break; // break stops the interval
  }
  log('liftoff');
}

// A timer that calls onTimeout after ms, but does not keep the program running.
export function watchdog(ms, onTimeout) {
  return setTimeout(onTimeout, ms).unref();
}

const guard = watchdog(2000, () => console.log('too slow'));
await countdown(3, 100);
console.log('guard keeps the program alive:', guard.hasRef());
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

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

// Logs from, from - 1, ..., 1, one line every ms milliseconds, then 'liftoff'.
export async function countdown(from, ms, log = console.log) {
  log('liftoff'); // no countdown yet
}

// A timer that calls onTimeout after ms, but does not keep the program running.
export function watchdog(ms, onTimeout) {
  return setTimeout(onTimeout, ms);
}

const guard = watchdog(2000, () => console.log('too slow'));
await countdown(3, 100);
console.log('guard keeps the program alive:', guard.hasRef());

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {countdown, watchdog} from './main.js';

test('counts down from 3, then liftoff', async () => {
  const log = [];
  await countdown(3, 10, (line) => log.push(line));
  assert.deepEqual(log, ['3', '2', '1', 'liftoff'], `the log is ${JSON.stringify(log)}`);
});

test('nothing is logged before the first interval has passed', async () => {
  const log = [];
  const done = countdown(2, 30, (line) => log.push(line));
  assert.deepEqual(log, [], `right after the call, the log is already ${JSON.stringify(log)}`);
  await done;
  assert.deepEqual(log, ['2', '1', 'liftoff'], `the log is ${JSON.stringify(log)}`);
});

test('the watchdog does not keep the program running', () => {
  const timer = watchdog(10000, () => {});
  const kept = timer.hasRef();
  clearTimeout(timer);
  assert.equal(kept, false, 'hasRef() should be false: call unref() on the timer');
});

test('the watchdog still fires while the program is running', async () => {
  const got = await new Promise((resolve) => {
    watchdog(20, () => resolve('fired'));
    setTimeout(resolve, 300, 'not fired');
  });
  assert.equal(got, 'fired', 'onTimeout should be called after ms');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Calling the function instead of passing it

function save() {
  console.log('saved');
}

setTimeout(save(), 100);

What Node.js prints

TypeError [ERR_INVALID_ARG_TYPE]: The "callback" argument must be of type function. Received undefined

Why, and the fix

save() runs at once and prints saved; setTimeout then receives its return value, undefined, instead of a function, and throws. Pass the function itself, setTimeout(save, 100), or wrap the call: setTimeout(() => save(), 100).

Awaiting the global setTimeout

console.log('waiting');
await setTimeout(100);
console.log('done');

What Node.js prints

TypeError [ERR_INVALID_ARG_TYPE]: The "callback" argument must be of type function. Received type number (100)

Why, and the fix

Without an import, setTimeout is the global callback version: its first argument must be a function, so 100 is rejected. The promise version lives in node:timers/promises: import {setTimeout} from 'node:timers/promises'; then await setTimeout(100) waits 100 ms.

unref() on a timers/promises promise

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

const later = setTimeout(5000, 'late');
later.unref();
console.log(await later);

What Node.js prints

TypeError: later.unref is not a function

Why, and the fix

setTimeout from node:timers/promises returns a Promise, not a Timeout, and a Promise has no unref method. Pass the option instead: setTimeout(5000, 'late', {ref: false}). Keep in mind that with nothing else pending, the program may then exit before the promise settles.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Timers: a threshold, not an appointment

setTimeout(fn, ms, ...args) calls fn once, with args, after at least ms; setInterval(fn, ms) calls it every ms. Both return a Timeout: pass it to clearTimeout or clearInterval to cancel. The docs promise no exact moment: the callback runs as close to the delay as the loop allows, and a delay below 1 becomes 1. The global functions need a function: setTimeout(save(), 100) passes save's result and throws ERR_INVALID_ARG_TYPE. node:timers/promises gives promise versions. B4.1 used await setTimeout(ms, value). Its setInterval(ms, value) returns an async iterator: for await gives value every ms, and break stops the interval.

Node's phases, and the queues between them

Each turn of the event loop goes through phases: timers (setTimeout and setInterval callbacks), pending callbacks, poll (I/O callbacks; the loop waits here), check (setImmediate) and close callbacks. In the main module, setTimeout(fn, 0) against setImmediate has no fixed order; inside an I/O callback, setImmediate always comes first. After every callback, Node drains the next tick queue, then the microtask queue (promise callbacks and queueMicrotask). So in CommonJS and in callbacks, nextTick runs first; in the top-level code of an ES module, microtasks run first. Recursive nextTick, or recursive microtasks, never let the loop move on. nextTick is Legacy in Node.js 24: use queueMicrotask.

What keeps a program running

Node.js exits when nothing is left that could call your code: no pending timers, immediates or I/O. Every active timer keeps it running, so a setInterval you never clear means the program never ends. timeout.unref() changes that for one timer: it may still fire, but if it is the only thing left, the program exits without waiting for it. That suits a watchdog or a background refresh. timeout.ref() undoes it; timeout.hasRef() tells you which is set. A promise from node:timers/promises has no unref method: pass {ref: false} in its options instead.

Sources

Last reviewed October 4, 2026