Scheduling: setTimeout and setInterval

Sometimes you don’t want a function to run at this exact moment. You want it to fire a second from now, or every few seconds, or the instant the current work finishes. That is what scheduling gives you: you hand a function to the environment and say “call this later.”

Two methods cover almost every case:

  • setTimeout runs a function once, after a delay you specify.
  • setInterval runs a function repeatedly, waiting the delay before the first call and then re-firing at that same interval.

Neither method lives in the JavaScript language spec itself. They come from the host environment. The browser provides them, Node.js provides them, and the behavior is close enough that you rarely notice the difference in everyday code.

setTimeout(f, 2000)
0ms
— wait 2s —
f()
done
setInterval(f, 2000)
0ms
— 2s —
f()
— 2s —
f()
— 2s —
f()
setTimeout fires once; setInterval keeps firing until you stop it

setTimeout

Here is the full shape of the call:

let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)

Each parameter:

func|code

The function to run later. You can also pass a string of code, which stays supported for historical reasons, but you should not use it. Pass a function.

delay

How long to wait before running, measured in milliseconds. 1000 ms is one second. If you leave it out, the default is 0.

arg1, arg2

Any extra values you list here get passed straight into the function as its arguments when it runs.

This schedules ring() to run one second from now:

function ring() {
  alert('Timer done');
}

setTimeout(ring, 1000);

Notice you pass ring, not ring(). You are handing over the function itself so the scheduler can call it later.

Extra arguments after the delay flow into the function:

function greet(phrase, who) {
  alert( phrase + ', ' + who );
}

setTimeout(greet, 1000, "Welcome", "Maya"); // Welcome, Maya

If the first argument is a string, the environment builds a function out of it. So this technically runs:

setTimeout("alert('Hello')", 1000);

Don’t do that. A code string is opaque to your tools, skips syntax checking until it runs, and behaves like a mini eval. An arrow function reads better and keeps everything in scope:

setTimeout(() => alert('Hello'), 1000);
setTimeout(ring, 1000)
passes the function object
scheduler stores it → calls it in 1s ✓
setTimeout(ring(), 1000)
runs ring now, passes undefined
scheduler stores undefined → nothing runs ✗
The parentheses decide whether you pass the recipe or the finished dish

Canceling with clearTimeout

Every setTimeout hands back a timer identifier. Keep it, and you can call the schedule off before it fires:

let timerId = setTimeout(...);
clearTimeout(timerId);

Here we schedule something, change our mind, and cancel. The result is that nothing happens at all:

let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // timer identifier

clearTimeout(timerId);
alert(timerId); // same identifier (it doesn't become null after canceling)

That last line is worth noting: canceling does not wipe the variable. timerId still holds the same value; it just no longer points at a live timer.

What that value is depends on the environment. In a browser it is a plain number. Node.js returns a timer object with its own extra methods. Since there is no shared spec forcing one shape, treat the identifier as an opaque token: store it, pass it to clearTimeout, and don’t rely on what type it is.

Browsers document their timer behavior in the timers section of the HTML Living Standard.

setInterval

setInterval reads exactly like setTimeout:

let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)

Same arguments, same meanings. The difference is repetition: instead of firing once, it keeps calling the function every delay milliseconds until you stop it with clearInterval(timerId).

This shows a message every two seconds, then shuts itself off after five:

// repeat with the interval of 2 seconds
let timerId = setInterval(() => alert('tick'), 2000);

// after 5 seconds stop
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);

The two calls work together: the interval keeps ticking, and a one-shot timeout cancels it later. Because the interval fires at 2s and 4s, you see two “tick” alerts before “stop” arrives at 5s.

Nested setTimeout

There are two ways to run something on a repeating schedule. One is setInterval. The other is a setTimeout that re-schedules itself:

/** instead of:
let timerId = setInterval(() => alert('tick'), 2000);
*/

let timerId = setTimeout(function tick() {
  alert('tick');
  timerId = setTimeout(tick, 2000); // (*)
}, 2000);

Line (*) is the trick: at the end of each run, the function schedules its own next run. It never uses setInterval, yet it repeats forever.

This pattern is more flexible than setInterval, because you decide the next delay only once you know how the current call went. Nothing is locked in ahead of time.

Say you poll a server every five seconds, but if the server reports overload you want to back off — 10 seconds, then 20, then 40 — instead of hammering it. Pseudocode:

let delay = 5000;

let timerId = setTimeout(function request() {
  ...send request...

  if (request failed due to server overload) {
    // increase the interval to the next run
    delay *= 2;
  }

  timerId = setTimeout(request, delay);

}, delay);

This “back off when things go wrong” idea is common enough to have a name: exponential backoff. setInterval can’t do it, because its delay is fixed the moment you call it. And if the scheduled work is CPU-heavy, you can measure how long it took and plan the next call sooner or later to smooth things out.

Nested setTimeout lets you control the gap between runs more precisely than setInterval. To see why, compare two versions of the same repeating call.

With setInterval:

let i = 1;
setInterval(function() {
  func(i++);
}, 100);

With nested setTimeout:

let i = 1;
setTimeout(function run() {
  func(i++);
  setTimeout(run, 100);
}, 100);

They look equivalent, but the timing differs. setInterval counts its 100 ms from the start of one call to the start of the next. The time func spends running is inside that window:

func
↑ run
func
func
func
100ms
100ms
100ms
setInterval: the delay is measured start-to-start, so func's own runtime eats into the gap

Did you catch it? The real pause between the end of one func and the start of the next is less than 100 ms — because part of each 100 ms interval is consumed by func running. That is expected behavior, not a bug.

It gets more dramatic if func sometimes takes longer than 100 ms. The engine can’t start the next call while it is still busy with the current one. So it waits for func to finish, then checks the schedule, sees the time is already up, and runs it again immediately. In the extreme case where func always outruns the delay, the calls happen back to back with no pause between them at all.

Nested setTimeout avoids that, because it starts counting only when the current call ends:

func
100ms
func
100ms
func
100ms
func
Nested setTimeout: the 100ms delay begins after func finishes, guaranteeing a real gap

Nested setTimeout guarantees a fixed minimum delay (100 ms here) between the end of one call and the start of the next, because the next call is planned only once the previous one is done.

Zero delay setTimeout

There is a special case: setTimeout(func, 0), or the shorthand setTimeout(func). This says “run func as soon as possible.” But “as soon as possible” does not mean “right now.” The scheduler only runs it after the current script finishes.

So the function is queued to fire the moment the running code hands control back. This prints “Hello” first, then “World”:

setTimeout(() => alert("World"));

alert("Hello");

Line one drops the call onto the schedule with a 0 ms delay. But the engine won’t look at the schedule until the current script — including that alert("Hello") on the last line — has completely finished. So the direct alert wins, and the scheduled one runs right after.

running now
setTimeout(…) → schedule
alert(“Hello”)
→ finishes →
queued, runs next
alert(“World”)
Zero-delay work waits behind the currently running script

There are deeper browser use cases for zero-delay timeouts, covered in Event loop: microtasks and macrotasks.

Summary

  • setTimeout(func, delay, ...args) runs func once after delay milliseconds; setInterval(func, delay, ...args) runs it over and over on that interval. Extra args pass straight into func.
  • Cancel a pending call with clearTimeout / clearInterval, using the identifier that setTimeout / setInterval returned.
  • Nested setTimeout is the flexible alternative to setInterval: you set the next delay at the end of each run, so you can control the gap between executions precisely and adapt it on the fly.
  • setTimeout(func, 0) (same as setTimeout(func)) schedules a call for “as soon as possible, but only after the current script finishes.”
  • Browsers clamp the minimum delay to 4 ms once you have five or more nested setTimeout calls, or for setInterval after its fifth call. It is a historical quirk.

None of these methods guarantee the exact delay you asked for. A browser timer can slow down for several reasons:

  • The CPU is under heavy load.
  • The tab is in the background.
  • The laptop is running on battery-saver mode.

Any of these can push the effective minimum timer resolution up to 300 ms or even 1000 ms, depending on the browser and the OS-level power settings.