Timers #

Timers are the mechanism for scheduling code execution in the future — whether once after a certain delay, periodically, or on the next event loop iteration. In Node.js, timers aren’t part of the JavaScript engine itself, but are provided by libuv through the event loop. Understanding how timers interact with the event loop isn’t just academic knowledge — it directly affects the execution order of asynchronous code, application performance, and the correctness of time-dependent logic.

The Event Loop and Timers #

Before diving into the API, it’s important to understand where timers sit in the Node.js event loop cycle.

flowchart TD
    A[Event loop starts] --> B["timers\nsetTimeout, setInterval\nthat have come due"]
    B --> C["pending callbacks\nI/O callbacks from the previous iteration"]
    C --> D["idle, prepare\ninternal Node.js"]
    D --> E["poll\nwait for new I/O"]
    E --> F["check\nsetImmediate callbacks"]
    F --> G["close callbacks\nsocket.on('close', ...)"]
    G --> A

    H["Microtask Queue\nPromise.then, queueMicrotask"] -- "run after\nevery phase" --> B

Priority order (from fastest to execute):

1. Microtask queue    — Promise.then(), queueMicrotask()
2. timers phase       — setTimeout(fn, 0), setInterval(fn, 0)
3. check phase        — setImmediate()
// demonstrating the execution order
console.log("1 — sync");

setTimeout(() => console.log("4 — setTimeout 0"), 0);

setImmediate(() => console.log("5 — setImmediate"));

Promise.resolve().then(() => console.log("2 — Promise.then"));

queueMicrotask(() => console.log("3 — queueMicrotask"));

console.log("6 — sync after setup");

// Output:
// 1 — sync
// 6 — sync after setup
// 2 — Promise.then
// 3 — queueMicrotask
// 4 — setTimeout 0
// 5 — setImmediate

setTimeout — Run Once After a Delay #

setTimeout schedules a function to run once after a minimum delay in milliseconds. “Minimum” is the key word — the actual delay can be longer if the event loop is busy.

// basic setTimeout
const timerId = setTimeout(() => {
  console.log("Dijalankan setelah 1 detik");
}, 1000);

// cancel before it runs
clearTimeout(timerId);

// setTimeout with arguments — arguments are passed to the callback
setTimeout((nama: string, angka: number) => {
  console.log(`Halo ${nama}, angka: ${angka}`);
}, 500, "Budi", 42);

// delay 0 — schedule for the next event loop iteration
setTimeout(() => {
  console.log("Ini berjalan setelah kode sync saat ini selesai");
}, 0);

// promise-based setTimeout — cleaner with async/await
function delay(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// an abortable delay with AbortController
function delayDenganAbort(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new DOMException("Dibatalkan", "AbortError"));
      return;
    }

    const timerId = setTimeout(resolve, ms);

    signal?.addEventListener("abort", () => {
      clearTimeout(timerId);
      reject(new DOMException("Dibatalkan", "AbortError"));
    }, { once: true });
  });
}

// usage
async function contohDelay(): Promise<void> {
  console.log("Mulai");
  await delay(1000);
  console.log("Setelah 1 detik");

  const controller = new AbortController();
  setTimeout(() => controller.abort(), 500); // cancel after 0.5 seconds

  try {
    await delayDenganAbort(2000, controller.signal);
    console.log("Ini tidak akan dicetak");
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") {
      console.log("Delay dibatalkan");
    }
  }
}

setTimeout for Retry Logic #

async function retryDenganBackoff<T>(
  fn: () => Promise<T>,
  options: {
    maksRetry?: number;
    delayAwal?: number;
    faktorBackoff?: number;
    delayMaks?: number;
    shouldRetry?: (err: unknown) => boolean;
  } = {}
): Promise<T> {
  const {
    maksRetry = 3,
    delayAwal = 1000,
    faktorBackoff = 2,
    delayMaks = 30_000,
    shouldRetry = () => true,
  } = options;

  let percobaan = 0;
  let delayMs = delayAwal;

  while (true) {
    try {
      return await fn();
    } catch (err) {
      percobaan++;

      if (percobaan >= maksRetry || !shouldRetry(err)) {
        throw err;
      }

      console.log(`Percobaan ${percobaan} gagal, retry dalam ${delayMs}ms...`);

      await delay(delayMs);

      // exponential backoff with jitter
      delayMs = Math.min(
        delayMs * faktorBackoff + Math.random() * 1000,
        delayMaks
      );
    }
  }
}

// usage
const data = await retryDenganBackoff(
  () => fetch("https://api.example.com/data").then((r) => r.json()),
  {
    maksRetry: 5,
    delayAwal: 500,
    faktorBackoff: 2,
    shouldRetry: (err) => {
      // only retry network errors, not 4xx
      return !(err instanceof Response && err.status >= 400 && err.status < 500);
    },
  }
);

setInterval — Repeated Execution #

setInterval runs a callback periodically. Note that the interval is actually the time between the end of the previous execution and the start of the next execution only if the callback is synchronous — for async callbacks that take longer than the interval, executions can pile up.

// basic setInterval
const intervalId = setInterval(() => {
  console.log("Dijalankan setiap detik:", new Date().toISOString());
}, 1000);

// cancel after 5 runs
let hitungan = 0;
const id = setInterval(() => {
  hitungan++;
  console.log(`Iterasi ke-${hitungan}`);
  if (hitungan >= 5) clearInterval(id);
}, 200);

// ANTI-PATTERN: setInterval with an async callback that may overlap
setInterval(async () => {
  await operasiLambat(); // ✗ if it takes > interval, executions pile up
}, 1000);

// CORRECT: use recursive setTimeout to guarantee no overlap
async function jalankanBerulang(
  fn: () => Promise<void>,
  intervalMs: number
): Promise<() => void> {
  let berjalan = true;

  async function loop(): Promise<void> {
    while (berjalan) {
      const mulai = Date.now();
      try {
        await fn();
      } catch (err) {
        console.error("Error dalam loop berulang:", err);
      }
      const durasi = Date.now() - mulai;
      const sisaDelay = Math.max(0, intervalMs - durasi);
      // wait the remaining interval after fn finishes
      if (berjalan) await delay(sisaDelay);
    }
  }

  loop(); // start the loop without awaiting

  // return a function to stop the loop
  return () => { berjalan = false; };
}

async function operasiLambat(): Promise<void> {
  await delay(500);
  console.log("Operasi selesai");
}

const hentikan = await jalankanBerulang(operasiLambat, 2000);
// after a while...
setTimeout(hentikan, 10_000); // stop after 10 seconds

Polling with setInterval #

// poll the status of a running job
async function polling<T>(
  cek: () => Promise<T | null>,
  options: {
    intervalMs?: number;
    timeoutMs?: number;
    pesan?: string;
  } = {}
): Promise<T> {
  const { intervalMs = 2000, timeoutMs = 60_000, pesan = "Menunggu..." } = options;

  const mulai = Date.now();

  while (true) {
    const hasil = await cek();

    if (hasil !== null) {
      return hasil;
    }

    if (Date.now() - mulai > timeoutMs) {
      throw new Error(`Polling timeout setelah ${timeoutMs}ms`);
    }

    console.log(pesan);
    await delay(intervalMs);
  }
}

// example: polling the status of a file export
async function tunggExport(jobId: string): Promise<string> {
  const url = await polling(
    async () => {
      const status = await cekStatusJob(jobId);
      if (status.selesai) return status.downloadUrl;
      return null;
    },
    {
      intervalMs: 3000,
      timeoutMs: 5 * 60_000, // 5 minutes
      pesan: `Menunggu export job ${jobId}...`,
    }
  );
  return url;
}

async function cekStatusJob(_jobId: string): Promise<{ selesai: boolean; downloadUrl: string }> {
  // implementation to check the job status
  return { selesai: false, downloadUrl: "" };
}

setImmediate — Run at the End of the Current Iteration #

setImmediate schedules a callback to run in the check phase — after I/O events but before the next timer. Useful for breaking up CPU-intensive operations so they don’t block the event loop.

// setImmediate — runs at the end of the current event loop iteration
setImmediate(() => {
  console.log("setImmediate dipanggil");
});

// cancelling
const immediateId = setImmediate(() => {
  console.log("Ini tidak akan berjalan");
});
clearImmediate(immediateId);

// use setImmediate for long CPU-intensive operations
// so they don't block I/O in the middle of processing
async function prosesDataBesar(data: number[]): Promise<number> {
  let total = 0;
  const chunkSize = 10_000;

  for (let i = 0; i < data.length; i += chunkSize) {
    const chunk = data.slice(i, i + chunkSize);

    // process one chunk
    for (const angka of chunk) {
      total += angka;
    }

    // yield to the event loop after every chunk
    // allows I/O and other events to be processed between chunks
    if (i + chunkSize < data.length) {
      await new Promise<void>((resolve) => setImmediate(resolve));
    }
  }

  return total;
}

// example: process 1 million numbers without blocking the server
const data = Array.from({ length: 1_000_000 }, (_, i) => i + 1);
const total = await prosesDataBesar(data);
console.log("Total:", total); // 500000500000

queueMicrotask — The Microtask Queue #

queueMicrotask adds a callback to the microtask queue — executed before any timer, even before setImmediate. Same as Promise.resolve().then() but without the Promise overhead.

// queueMicrotask — the fastest, before timers
queueMicrotask(() => {
  console.log("microtask");
});

setTimeout(() => console.log("setTimeout"), 0);

// Output:
// microtask
// setTimeout

// use case: batch updates — collect several changes and process them at once
class BatchUpdater {
  private perubahan = new Set<string>();
  private terjadwal = false;

  tandaiPerubahan(key: string): void {
    this.perubahan.add(key);

    // schedule the flush on the next microtask
    // if called many times synchronously, the flush only happens once
    if (!this.terjadwal) {
      this.terjadwal = true;
      queueMicrotask(() => this.flush());
    }
  }

  private flush(): void {
    console.log("Memproses perubahan:", [...this.perubahan]);
    this.perubahan.clear();
    this.terjadwal = false;
  }
}

const updater = new BatchUpdater();

// all these changes are collected and processed once
updater.tandaiPerubahan("user.nama");
updater.tandaiPerubahan("user.email");
updater.tandaiPerubahan("user.alamat");
// Output: Memproses perubahan: ["user.nama", "user.email", "user.alamat"]

Debounce and Throttle #

Debounce and throttle are patterns for limiting how often a function runs — very common for UI events like input, scroll, and resize.

flowchart LR
    subgraph "Without debounce/throttle"
        A1[event] --> F1[fn]
        A2[event] --> F2[fn]
        A3[event] --> F3[fn]
        A4[event] --> F4[fn]
        A5[event] --> F5[fn]
    end

    subgraph "With debounce (300ms)"
        B1[event]
        B2[event]
        B3[event]
        B4[event]
        B5[event] --> G1[fn — once after stopping]
    end

    subgraph "With throttle (300ms)"
        C1[event] --> H1[fn]
        C2[event]
        C3[event] --> H2[fn]
        C4[event]
        C5[event] --> H3[fn]
    end

Debounce #

Debounce delays execution until there are no new calls for a certain amount of time. Suitable for search-as-you-type, form validation, or auto-save.

function debounce<T extends (...args: unknown[]) => unknown>(
  fn: T,
  delayMs: number
): {
  (...args: Parameters<T>): void;
  cancel: () => void;
  flush: (...args: Parameters<T>) => void;
} {
  let timerId: ReturnType<typeof setTimeout> | null = null;

  function debounced(...args: Parameters<T>): void {
    // cancel the previous timer every time it's called
    if (timerId !== null) clearTimeout(timerId);

    timerId = setTimeout(() => {
      timerId = null;
      fn(...args);
    }, delayMs);
  }

  // cancel without running
  debounced.cancel = (): void => {
    if (timerId !== null) {
      clearTimeout(timerId);
      timerId = null;
    }
  };

  // run immediately and cancel the pending timer
  debounced.flush = (...args: Parameters<T>): void => {
    debounced.cancel();
    fn(...args);
  };

  return debounced;
}

// example: search with debounce
const cariProduk = debounce(async (keyword: string) => {
  console.log("Mencari:", keyword);
  // await fetch(`/api/produk?q=${keyword}`)
}, 300);

// while the user types "laptop gaming" character by character
// only one request is sent — 300ms after they stop typing
cariProduk("l");
cariProduk("la");
cariProduk("lap");
cariProduk("lapt");
cariProduk("lapto");
cariProduk("laptop");
cariProduk("laptop ");
cariProduk("laptop g");
cariProduk("laptop ga");
cariProduk("laptop gam");
cariProduk("laptop gami");
cariProduk("laptop gamin");
cariProduk("laptop gaming");
// After 300ms with no more input:
// Output: Mencari: laptop gaming

Throttle #

Throttle ensures a function runs at most once within a given time interval. Suitable for scroll handlers, resize handlers, or client-side API call rate limiting.

function throttle<T extends (...args: unknown[]) => unknown>(
  fn: T,
  intervalMs: number
): (...args: Parameters<T>) => void {
  let terakhirDijalankan = 0;
  let timerId: ReturnType<typeof setTimeout> | null = null;

  return function throttled(...args: Parameters<T>): void {
    const sekarang = Date.now();
    const sisaWaktu = intervalMs - (sekarang - terakhirDijalankan);

    if (sisaWaktu <= 0) {
      // the interval has passed — run now
      if (timerId !== null) {
        clearTimeout(timerId);
        timerId = null;
      }
      terakhirDijalankan = sekarang;
      fn(...args);
    } else {
      // the interval hasn't passed — schedule at the end of the interval
      if (timerId !== null) clearTimeout(timerId);
      timerId = setTimeout(() => {
        terakhirDijalankan = Date.now();
        timerId = null;
        fn(...args);
      }, sisaWaktu);
    }
  };
}

// example: update scroll position at most 10 times per second
const handleScroll = throttle((scrollY: number) => {
  console.log("Scroll position:", scrollY);
  // update the UI based on the scroll position
}, 100);

// simulate many scroll events
for (let i = 0; i < 20; i++) {
  setTimeout(() => handleScroll(i * 50), i * 30); // scroll every 30ms
}
// handleScroll is only called every 100ms even though events come every 30ms

High-Precision Timers #

Date.now() has millisecond precision, but for benchmarking or performance measurement, use process.hrtime.bigint() which has nanosecond precision.

// Date.now() — millisecond precision (ms)
const mulaiMs = Date.now();
await delay(100);
console.log(`Durasi: ${Date.now() - mulaiMs}ms`);

// process.hrtime.bigint() — nanosecond precision (ns)
const mulaiNs = process.hrtime.bigint();
await delay(100);
const durasiNs = process.hrtime.bigint() - mulaiNs;
console.log(`Durasi: ${durasiNs}ns`);               // e.g. 100234567ns
console.log(`Durasi: ${Number(durasiNs) / 1e6}ms`); // e.g. 100.234567ms

// a benchmarking helper
async function ukurWaktu<T>(
  label: string,
  fn: () => Promise<T> | T
): Promise<T> {
  const mulai = process.hrtime.bigint();
  const hasil = await fn();
  const durasi = process.hrtime.bigint() - mulai;

  console.log(`[${label}] ${(Number(durasi) / 1e6).toFixed(3)}ms`);
  return hasil;
}

// compare the performance of two implementations
await ukurWaktu("Array.reduce", () => {
  return Array.from({ length: 1_000_000 }, (_, i) => i).reduce((a, b) => a + b, 0);
});

await ukurWaktu("for loop", () => {
  let sum = 0;
  for (let i = 0; i < 1_000_000; i++) sum += i;
  return sum;
});

// a high-precision timeout — useful for SLA monitoring
async function denganTimeout<T>(
  fn: () => Promise<T>,
  timeoutMs: number,
  label: string = "operasi"
): Promise<T> {
  const mulai = Date.now();

  const hasilPromise = fn();
  const timeoutPromise = delay(timeoutMs).then(() => {
    throw new Error(`${label} timeout setelah ${timeoutMs}ms`);
  });

  try {
    return await Promise.race([hasilPromise, timeoutPromise]);
  } finally {
    const durasi = Date.now() - mulai;
    if (durasi > timeoutMs * 0.8) {
      console.warn(`[WARN] ${label} butuh ${durasi}ms (mendekati timeout ${timeoutMs}ms)`);
    }
  }
}

Schedulers — Cron-like Scheduling in Node.js #

For scheduling repeated tasks at specific times (every hour, every day at 00:00, etc.), calculate the delay to the next occurrence:

// calculate the milliseconds until a certain next time
function msHingga(jam: number, menit: number = 0, detik: number = 0): number {
  const sekarang = new Date();
  const target = new Date();
  target.setHours(jam, menit, detik, 0);

  if (target <= sekarang) {
    // today's time has passed — schedule for tomorrow
    target.setDate(target.getDate() + 1);
  }

  return target.getTime() - sekarang.getTime();
}

// run a task every day at a certain time
function jadwalkanHarian(
  jam: number,
  menit: number,
  task: () => Promise<void>
): () => void {
  let timerId: ReturnType<typeof setTimeout>;
  let berjalan = true;

  async function jadwalBerikutnya(): Promise<void> {
    if (!berjalan) return;

    const delay = msHingga(jam, menit);
    console.log(`Task dijadwalkan dalam ${Math.round(delay / 1000 / 60)} menit`);

    timerId = setTimeout(async () => {
      if (!berjalan) return;
      try {
        await task();
      } catch (err) {
        console.error("Error dalam scheduled task:", err);
      }
      jadwalBerikutnya(); // schedule for the next day
    }, delay);
  }

  jadwalBerikutnya();

  return () => {
    berjalan = false;
    clearTimeout(timerId);
  };
}

// example: send a daily report every day at 07:00
const hentikanLaporan = jadwalkanHarian(7, 0, async () => {
  console.log("Mengirim laporan harian...");
  // await kirimLaporan();
});

// stop it when the application shuts down
process.on("SIGTERM", () => {
  hentikanLaporan();
});

When to Use Which Timer #

setTimeout(fn, ms):
  ✓ Run once after a delay — animations, notifications, timeouts
  ✓ Retry with backoff — wait before trying again
  ✓ Debounce and throttle — limit execution frequency
  ✓ Delays in testing — simulate waiting times

setInterval(fn, ms):
  ✓ Periodically poll status — health checks, data updates
  ✓ Simple frame animations (prefer requestAnimationFrame in the browser)
  ✗ Async callbacks — use recursive setTimeout to avoid overlap

setImmediate(fn):
  ✓ Yield to the event loop between long CPU-intensive operations
  ✓ Make sure I/O callbacks are processed before your code runs
  ✓ A more deterministic alternative to setTimeout(fn, 0) in Node.js

queueMicrotask(fn):
  ✓ Batch updates — collect changes and process once
  ✓ Schedule something after the current sync code but before I/O
  ✗ I/O operations — microtasks shouldn't contain long async operations

Summary #

  • Timers in Node.js are a “minimum delay” — the actual delay can be longer if the event loop is busy; don’t rely on millisecond precision for critical logic.
  • Wrap setTimeout in a Promise with a delay(ms) function so it works with async/await — far cleaner than nested callbacks.
  • Use recursive setTimeout, not setInterval, for async callbacks — setInterval doesn’t wait for the callback to finish, so executions can pile up if the callback is slower than the interval.
  • setImmediate for yielding to the event loop between long CPU-intensive operations — this prevents the server from becoming unresponsive to incoming I/O requests.
  • queueMicrotask for batch updates — schedule a flush at the end of the current sync code; useful for combining many small changes into one operation.
  • Debounce for user input — delay execution until the user stops; throttle for high-frequency events like scroll — run at most once per interval.
  • process.hrtime.bigint() for accurate performance measurement — nanosecond precision is far better than Date.now() for benchmarking.
  • Always keep the timer reference from setTimeout/setInterval and call clearTimeout/clearInterval on cleanup — uncancelled timers are a common source of memory leaks.

← Previous: Events   Next: Date & Time →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact