Timers and asynchrony
Why it matters
Until now programs ran strictly top to bottom: line after line, with no pauses. But real programs constantly need to wait: show a tip after a second, update a clock every second, wait for the server's response, react to a click. Code that runs not right away but later is called asynchronous.
Timers are the simplest asynchronous code. They clearly show JavaScript's main rule: delayed code never
interrupts the current code, it waits for its turn. Without understanding this rule you won't understand
promises, async/await, or when React updates the page.
setTimeout — run once, later
setTimeout(() => {
console.log("A second has passed");
}, 1000);
console.log("Timer set");
Output:
Timer set
A second has passed
- The first argument is the function (callback) to call.
- The second is the delay in milliseconds: 1000 ms = 1 second.
setTimeoutdoesn't wait: it notes "call this in a second" and returns immediately. That's why "Timer set" is printed first.
You can pass data to the callback through a closure:
function remind(text, ms) {
setTimeout(() => console.log("Reminder: " + text), ms);
}
remind("drink water", 200);
remind("stretch", 100);
Output:
Reminder: stretch
Reminder: drink water
They fire in order of time, not in the order of the calls.
Cancel: clearTimeout
setTimeout returns a timer id. You can use it to cancel the timer before it fires:
const id = setTimeout(() => console.log("Boom!"), 500);
clearTimeout(id);
console.log("Timer cancelled");
Output:
Timer cancelled
"Boom!" will never be printed.
setInterval — repeat
setInterval(callback, ms) calls the function again and again with an ms pause until it is stopped
with clearInterval:
let count = 0;
const id = setInterval(() => {
count++;
console.log("Tick " + count);
if (count === 3) {
clearInterval(id);
console.log("Stop");
}
}, 100);
Output:
Tick 1
Tick 2
Tick 3
Stop
A forgotten clearInterval is a common mistake: the interval will run forever.
Why delayed code waits: the event loop
JavaScript runs code in one thread: at any moment only one function is running. Until it finishes, nothing else can start. It works like this:
- First, all the ordinary (synchronous) code runs, top to bottom.
- Callbacks that are ready to run (a timer that fired, a click, a server response) join a queue.
- When the current code has finished, the event loop takes the next callback from the queue and runs it completely. Then the next one — and so on for as long as the program runs.
That's why even a timer with a delay of 0 doesn't fire right away:
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
Output:
1
3
2
setTimeout(…, 0) means "as soon as possible, but after the current code". And the delay is the
minimum waiting time: if the program is busy with a long loop, the timer waits until it is free.
const start = Date.now();
setTimeout(() => console.log("Timer was late: " + (Date.now() - start >= 100)), 10);
while (Date.now() - start < 100) {
// a busy loop for 100 ms — the queue waits
}
console.log("Loop finished");
Output:
Loop finished
Timer was late: true
The timer was ready after 10 ms, but it ran only after the loop freed the thread. That's why in the browser heavy computations "freeze" the page: clicks wait in the queue too.
Callbacks and "callback hell"
An asynchronous function can't simply give back its result with return — the result doesn't exist yet. So
you ask it: "when it's ready, call this function":
function loadUser(id, callback) {
setTimeout(() => callback({ id, name: "Ann" }), 50); // pretend server response
}
loadUser(1, (user) => console.log("Loaded: " + user.name));
console.log("Loading...");
Output:
Loading...
Loaded: Ann
If there are several steps and each waits for the previous one, the callbacks get nested inside each other:
loadUser(1, (user) => {
loadOrders(user, (orders) => {
loadDetails(orders[0], (details) => {
// ...and even deeper
});
});
});
This "staircase" is called callback hell: it is hard to read, and errors have to be handled at every level. Promises solve this problem — the next topic.
Debounce: wait until the user stops
A useful trick built on timers. The user types in a search box, and sending a request to the server for every letter is wasteful. Better to wait until they stop typing:
function debounce(fn, ms) {
let timer; // lives in the closure between calls
return (...args) => {
clearTimeout(timer); // a new call cancels the old timer
timer = setTimeout(() => fn(...args), ms);
};
}
const search = debounce((q) => console.log("Searching: " + q), 100);
search("j");
search("ja");
search("jav");
Output:
Searching: jav
Three quick calls — one request, with the last value.
How it works inside
Timers are counted not by JavaScript itself but by the environment: the browser or Node.js. When
setTimeout is called, the environment remembers the function and the time. When the time comes, it puts the
callback into the task queue. The JavaScript engine runs the synchronous code to the end, and then the
event loop takes tasks from the queue one at a time.
Three consequences follow:
- a timer callback never runs in the middle of another function;
- the delay means "not earlier than", not "exactly after";
- while there are no callbacks, the program simply waits. In Node.js the program exits when the queue is empty and no timers are left.
Common mistakes
- Calling instead of passing.
setTimeout(sayHi(), 1000)callssayHiright away and gives the timer its result. You needsetTimeout(sayHi, 1000)orsetTimeout(() => sayHi(), 1000). - Waiting for a result with
return. A value you get inside a timer callback can't be returned from the outer function: it has already finished. You need a callback (or a promise). - Forgot
clearInterval. An interval keeps running until it is stopped. - Seconds instead of milliseconds.
setTimeout(fn, 5)is 5 ms, not 5 seconds. - Thinking
0means "immediately". The callback runs only after all the current code.
Cheat sheet
| What | How |
|---|---|
| Run once later | const id = setTimeout(fn, ms) |
| Cancel | clearTimeout(id) |
| Repeat | const id = setInterval(fn, ms) |
| Stop repeating | clearInterval(id) |
| After the current code | setTimeout(fn, 0) |
| Order | first all synchronous code, then the queue |
| Debounce | clearTimeout(timer); timer = setTimeout(...) |