Timers, Ordering, and Race Conditions in JavaScript
By the end of this page you will know why a delayed callback runs later than you asked, why a search box can show results for a word you already deleted, and how to stop a handler firing on every keystroke.
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.
setTimeout and setInterval
These are the two timers you meet first. setTimeout runs a callback once after a delay, and setInterval keeps running it until something stops it. Both hand back a handle, and passing that handle to the matching clear function is the only way to cancel.
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); // node output, give or take a few milliseconds:
// 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.
What Actually Runs First
A delay of zero does not mean now. This is the ordering that surprises everyone once:
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 won, because nothing had been queued on its behalf 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.
The Stale Response Problem
Now the bug this page exists for. Two requests start, the second finishes first, and then the first one lands on top of it:
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.
When the work is a real network request, cancelling beats discarding. Keep the AbortController for the request in flight and abort it when a new one starts, so the browser stops pulling down a result you are going to throw away. Bytes already transferred and work the server has already done are not refunded. The fetch guide covers the setup.
Debounce and Throttle
The guard above handles the results. These two patterns reduce how many requests start in the first place, and they are not interchangeable.
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); // node output, one representative run.
// 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.
Cleaning Up When the Owner Disappears
This section is browser-side. On a real page, the thing that started a timer or a listener can be removed while the timer is still counting, and nothing stops it on your behalf.
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 actually works.
Other frameworks name the hook differently and the obligation is identical.
Skipping it produces a recognisable 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 genuinely optional.
Timers running far slower than expected in a browser. Background tabs are throttled deliberately, to at most once per second and sometimes much less often or suspended entirely. Do not build anything that depends on a background tab keeping accurate time.
Frequently Asked Questions
Is setTimeout with zero the same as running immediately?
No. Zero means as soon as possible, and as soon as possible is after the current run of code finishes and after the promise callbacks already queued by then. It is a way of saying later, not now, which is occasionally useful and much more often a sign that something else should be fixed.
What is the difference between debounce and throttle?
Debounce waits for the activity to stop, so a burst of twenty keystrokes produces one call at the end. Throttle lets at most one call through per interval while the activity continues. Search suggestions and autosave want debounce; scroll and resize handlers want throttle.
Why is my timer late?
Because a timer is a request, not a guarantee. The callback becomes eligible once the delay is up and runs only when the current work finishes, so any long synchronous loop delays it. Background browser tabs also slow timers deliberately, to at most once per second and sometimes much less often or not at all.
Do you need a library for any of this?
No. Debounce and throttle are a few lines each, as this page shows, and request guarding is a comparison and an early return. Libraries add options such as leading and trailing calls or cancellation, which are worth having on a big app and are not worth a dependency on a small one.
Related
- JavaScript Async for the full topic overview
- Promises and async/await for the promise behaviour behind the ordering rules
- Fetch, JSON, and the Network for cancelling a real request with AbortController
- Functions, Callbacks, and Closures for the live bindings both guards depend on
- JavaScript for the language guide and the wider learning path
Sources
-
[1]
setTimeout(developer.mozilla.org)
-
[2]
setInterval(developer.mozilla.org)
-
[3]
Timers(nodejs.org)
Read Next
Requesting data with fetch, the response.ok check most tutorials skip, parsing JSON, sending data with POST, telling a bad answer from no usable answer, and timeouts with AbortController.
What a promise is and the three states it can be in, then and catch, the async and await keywords, waiting on overlapping work with Promise.all, and where a thrown error ends up.
The JavaScript that runs later: promises and await, fetching data over the network, and the ordering rules behind timers and race conditions.