Timers, Ordering, and Race Conditions in JavaScript

Published Updated

Timing bugs show up in three familiar ways: a delayed callback runs later than you asked, a search box shows results for a word you already deleted, and a handler fires on every keystroke.

This is part of our asynchronous JavaScript guides. Start with the JavaScript overview if functions and objects are new to you.

You need Node installed to run the examples. Two of them are browser-side and are marked where they appear, because they involve a page that can disappear while your code is still running.

How Do setTimeout and setInterval Differ?

setTimeout schedules one callback after a delay. setInterval schedules repeated callbacks until it is cleared. Save the returned handle and pass it to clearTimeout or clearInterval when the work is no longer needed.

What the handle is depends on the host. Browsers give you a number and Node gives you a Timeout object, so keep whatever you were given rather than assuming it is an id.

let ticks = 0;
const start = Date.now();

const timerId = setInterval(() => {
  ticks += 1;
  console.log("tick", ticks, "at", Date.now() - start, "ms");
  if (ticks === 3) clearInterval(timerId);
}, 100);
// Illustrative Node output; exact timings vary:
// tick 1 at 101 ms
// tick 2 at 204 ms
// tick 3 at 305 ms

Notice the drift. Each tick arrived a little later than its slot, and how much lateness accumulates depends on the host and on what else is running. Either way an interval is a poor clock, so when elapsed time matters read it from Date.now as the example does rather than counting ticks.

An interval that nobody clears runs forever. In Node it also keeps the process alive, and in a browser it keeps firing long after whatever started it has gone from the screen.

Do Promise Callbacks Run Before Timers?

Queued promise callbacks run as microtasks after the current code finishes and before the next timer task. A zero-delay timer therefore runs later, not immediately. This example shows the order:

console.log("1: top of the file");

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

Promise.resolve().then(() => console.log("3: promise callback"));

console.log("2: bottom of the file");
// node output:
// 1: top of the file
// 2: bottom of the file
// 3: promise callback
// 4: setTimeout 0

Two rules explain the whole result. Ordinary code runs to the end first, and then the promise callbacks already waiting are taken before any timer callback.

The second rule has a sharper edge than it looks. Promise callbacks are drained completely, including any that get added while draining, before a single timer callback is allowed in:

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

Promise.resolve()
  .then(() => console.log("microtask 1"))
  .then(() => console.log("microtask 2"))
  .then(() => console.log("microtask 3"));
// node output:
// microtask 1
// microtask 2
// microtask 3
// task A
// task B

The three promise callbacks were queued one at a time, each by the one before it, and all three still ran before a timer that had been waiting since the first line.

The rule is about what is already queued, not about promises in general. Awaiting something that has already settled resumes before a zero-delay timer, because the resumption is queued at once. Awaiting something that settles later does not:

async function waitForSlow() {
  console.log("2: about to await a promise that settles in 20ms");
  await new Promise((resolve) => setTimeout(resolve, 20));
  console.log("4: the await resumed");
}

setTimeout(() => console.log("3: setTimeout 0"), 0);
waitForSlow();
console.log("1: end of the file");
// node output:
// 2: about to await a promise that settles in 20ms
// 1: end of the file
// 3: setTimeout 0
// 4: the await resumed

The zero-delay timer ran first because the awaited promise had not settled, so the continuation after await was not queued yet. A promise callback only outranks a timer once it is waiting in the queue, and a promise that has not settled has queued nothing at all.

The other way is simpler. A timer cannot interrupt code that is still running, so a slow loop delays every callback behind it:

const start = Date.now();
setTimeout(() => console.log("waited", Date.now() - start, "ms"), 100);

const end = Date.now() + 300;
while (Date.now() < end) {}
console.log("blocking loop finished");
// node output:
// blocking loop finished
// waited 302 ms

The delay was 100 and the callback ran after 302. The timer became eligible somewhere around its threshold and had nowhere to run, which is why a delay is a minimum rather than a promise.

How Do You Prevent Stale Search Results?

Prevent stale search results by checking that a response belongs to the latest request before showing it. Otherwise an older, slower request can overwrite newer results:

function search(term, ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`results for ${term}`), ms);
  });
}

let shown = null;

async function runSearch(term, ms) {
  const results = await search(term, ms);
  shown = results;
  console.log("rendered:", results);
}

runSearch("rep", 300);
runSearch("report", 50);

setTimeout(() => console.log("final state:", shown), 400);
// node output:
// rendered: results for report
// rendered: results for rep
// final state: results for rep

The reader typed report and the screen ends up showing results for rep. Nothing threw, no request failed, and the code reads correctly line by line.

The fix is to record which request is the current one and let the others fall on the floor. One shared counter outside the function, one private copy inside each call:

let latestRequestId = 0;

async function runSearch(term, ms) {
  const requestId = ++latestRequestId;
  const results = await search(term, ms);

  if (requestId !== latestRequestId) {
    console.log("discarded stale:", results);
    return;
  }

  console.log("rendered:", results);
}

runSearch("rep", 300);
runSearch("report", 50);
// node output:
// rendered: results for report
// discarded stale: results for rep

The two variables do different jobs, and the difference is the whole trick. Each call gets its own requestId, while latestRequestId is a single variable every call shares.

A closure captures the variable itself, not a copy of its value, so the comparison after the await reads whatever latestRequestId holds at that moment. The closures guide covers that live binding in full.

For real network requests, abort the previous request when a new search starts and keep the request-id check before updating the page. Cancellation can save work, but it does not undo a response already processed or work already done by the server. The fetch cancellation guide shows how to use AbortController.

How Do Debounce and Throttle Differ?

Debounce waits for a pause before calling a function. Throttle limits how often a function runs while activity continues. Both reduce calls, but neither alone prevents an older request from overwriting newer results.

Debounce waits for a pause. Every new call cancels the pending one, so a burst of typing produces a single call at the end:

function debounce(callback, waitMs) {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => callback(...args), waitMs);
  };
}

const save = debounce((text) => console.log("saved:", text), 200);

save("r");
save("re");
save("rep");
save("report");
// node output:
// saved: report

Four calls, one save, and the one that survived is the last. That is what you want for search suggestions, autosave, and anything else where only the final value matters.

Throttle is the opposite trade. It lets at most one call through per interval while the activity continues, which suits scroll and resize handlers where you do want updates during the movement:

function throttle(callback, intervalMs) {
  let lastRun = 0;
  return (...args) => {
    const now = Date.now();
    if (now - lastRun < intervalMs) return;
    lastRun = now;
    callback(...args);
  };
}

const report = throttle((position) => console.log("position", position), 100);

let position = 0;
const timerId = setInterval(() => {
  report(++position);
  if (position === 25) clearInterval(timerId);
}, 20);
// Illustrative output.
// The exact positions and the number of calls shift with timing:
// position 1
// position 6
// position 11
// position 16
// position 21

Twenty-five events, a handful of calls. This version fires on the leading edge and has no trailing call, so the last event in a burst can be dropped. Both helpers work because the returned function closes over a variable that survives between calls, the same live binding the stale-request guard relies on.

One rule about placement. Create the debounced function once and reuse it, because building a new one on every keystroke gives each call its own private timer and cancels nothing.

How Do You Clean Up Timers When a Component Closes?

Clear timers when the component or feature that started them closes. If it also registered event listeners, remove those in the same cleanup function. Removing a page element does not stop its timers.

Whatever starts something has to be able to stop it. Keep the timer id, and keep the AbortController, so that both have an owner:

// browser-side
const controller = new AbortController();
const timerId = setInterval(refreshPrices, 5000);

button.addEventListener("click", refreshPrices, { signal: controller.signal });

function teardown() {
  clearInterval(timerId);
  controller.abort();
}

The signal option is worth the habit. One abort call removes every listener registered with that signal, which beats matching each removeEventListener to the exact function reference it was added with.

In React, teardown is what you return from useEffect, and the framework calls it when the component is removed or before the effect is set up again. In development, Strict Mode also runs an extra setup and cleanup cycle on purpose, which is a good way to find out whether your cleanup works.

Other frameworks name the hook differently and the obligation is identical.

Skipping it produces a recognizable class of bug. A timer that fires against a screen that is gone, a request whose result is written into nothing, or a counter that runs twice as fast because the old interval is still going alongside the new one.

Pitfalls and Debugging

Calling the function instead of passing it. setTimeout(save(), 1000) runs save now and hands its return value to the timer. Node rejects that with TypeError: The "callback" argument must be of type function, while a browser accepts the undefined in silence, so the only symptom there is save running too early.

The fix is setTimeout(save, 1000), or setTimeout(() => save(draft), 1000) when arguments are involved.

An interval that speeds up. Something started a second one without clearing the first, usually a component that mounted twice. Store the id and clear it before starting another.

A debounce that never debounces. The debounced function is being created inside the handler, so each keystroke gets a fresh timer. Create it once, outside.

The screen shows results for the wrong input. The stale response problem. Add the request-id guard, or abort the previous request before starting the next.

An error inside a timer callback that no try block catches. The callback runs long after the code that scheduled it, so the surrounding try is finished. Put the handling inside the callback.

A Node script that will not exit. A pending interval keeps the process alive. Clear it, or call unref on the returned timer when the work is optional.

Timers running far slower than expected in a browser. Background tabs are throttled deliberately, with limits that vary by browser and page state. Do not build anything that depends on a background tab keeping accurate time.

Frequently Asked Questions

Does setTimeout with a zero delay run immediately?

setTimeout with a zero delay does not run immediately. Its callback waits until the current code finishes and queued microtasks have run. Other work and browser timer limits can delay it further.

What is the difference between JavaScript debounce and throttle?

JavaScript debounce waits for a pause in activity before calling a function. Throttle limits calls to a chosen interval while activity continues. Debounce often suits search input; throttle often suits scroll updates. Leading and trailing behavior depends on the implementation.

Why does a JavaScript timer run late?

A JavaScript timer can run late because its delay is a minimum wait, not an exact appointment. Long-running code, queued work, and browser throttling can postpone the callback. Background-tab limits vary by browser and page state.

Do JavaScript debounce and throttle need a library?

Basic JavaScript debounce and throttle functions do not need a library. A library can be useful when you need tested options for cancellation, maximum waits, or leading and trailing calls.

Sources

  1. [1]
    setTimeout
    (developer.mozilla.org)
  2. [2]
    setInterval
    (developer.mozilla.org)
  3. [3]
    Timers
    (nodejs.org)