---
title: Timers
source: 'https://academia.sh/en/courses/asynchronous-javascript/timers'
course: 'Asynchronous JavaScript and the Runtime'
language: en
updated: '2026-08-17T18:09:43+00:00'
license: 'CC BY-SA 4.0'
---

# Timers

The delay as a lower bound, the order of equal-delay timers, drift and overlap in interval scheduling, the self-scheduling loop, and cancellation.

The previous lesson showed that calls placing work in the queue only register it. The
most commonly used registration tool is timers, and the number given to them is often
misread: the call `setTimeout(f, 10)` does not say `f` will run in ten milliseconds.

This lesson establishes what that number actually says, what order equal-delay timers
enter, and where interval scheduling drifts. The stream's first running piece will also
appear here, as a fake station driven by a timer.

## Delay Is a Lower Bound

The call `setTimeout(callback, delay)` means: *the callback is not queued until the
elapsed time reaches `delay`.* Once it is queued, when it actually runs depends on how
long the work ahead of it takes.

Timers registered at the same moment enter the queue by their delay; if their delays
are equal, they keep their registration order.

```js
setTimeout(() => console.log("4 — 20 ms"), 20);
setTimeout(() => console.log("3 — 10 ms"), 10);
setTimeout(() => console.log("1 — 0 ms, first registration"), 0);
setTimeout(() => console.log("2 — 0 ms, second registration"), 0);
```

```
1 — 0 ms, first registration
2 — 0 ms, second registration
3 — 10 ms
4 — 20 ms
```

A zero delay does not mean "right now," it means "at the earliest opportunity." Host
environments also apply a floor delay on top of this: the HTML standard raises the
lower bound to four milliseconds for timers nested more than five deep; server
runtimes commonly round a zero delay up to one millisecond too. Whatever the number,
nothing is promised beyond the lower bound.

## Blocking Exceeds the Lower Bound

The lower bound has no upper bound. If the work in the previous turn runs long, a timer
whose duration has long since elapsed still waits in the queue.

```js
function busyWait(duration) {
  const deadline = Date.now() + duration;
  while (Date.now() < deadline) {
    // Deliberately keeps the thread busy.
  }
}

setTimeout(() => {
  console.log("1 — 10 ms timer; holds the thread for 50 ms");
  busyWait(50);
  console.log("2 — 10 ms timer done");
}, 10);

setTimeout(() => console.log("3 — 20 ms timer"), 20);
```

```
1 — 10 ms timer; holds the thread for 50 ms
2 — 10 ms timer done
3 — 20 ms timer
```

The second timer's duration elapsed while the first callback was still running. Under
the run-to-completion rule, it could not cut in; it waited for the first piece of work
to finish. This is the only reason a measured delay can turn out larger than the
requested one: the thread was busy.

## Interval Scheduling and Overlap

`setInterval(callback, interval)` places the callback in the queue at regular
intervals. The interval is measured by the times *of registration*, not by the time
*after the callback finishes*. If the callback takes longer than the interval,
consecutive runs leave no gap between them.

The two programs below differ by only one line. In the first, the callback is short:

```js
let tick = 0;
const id = setInterval(() => {
  tick += 1;
  console.log("interval tick", tick);
  if (tick === 3) clearInterval(id);
}, 5);

setTimeout(() => console.log("30 ms timer"), 30);
```

```
interval tick 1
interval tick 2
interval tick 3
30 ms timer
```

Three ticks completed at five-millisecond intervals, and the timer at the thirtieth
millisecond entered the queue after. In the second, each tick does twenty milliseconds
of work:

```js
function busyWait(duration) {
  const deadline = Date.now() + duration;
  while (Date.now() < deadline) {
    // Deliberately keeps the thread busy.
  }
}

let tick = 0;
const id = setInterval(() => {
  tick += 1;
  console.log("interval tick", tick);
  busyWait(20);
  if (tick === 3) clearInterval(id);
}, 5);

setTimeout(() => console.log("30 ms timer"), 30);
```

```
interval tick 1
interval tick 2
30 ms timer
interval tick 3
```

The order changed. The second tick started before the thirtieth millisecond arrived and
held the thread for twenty milliseconds; the timer could only run once that work
finished. The third tick then came right after, with almost no pause.

This behavior has two consequences. First, in a flow built with `setInterval`, a rest
period between callbacks is not guaranteed. Second, if asynchronous work is started
inside the callback, a new run can start before the previous one finishes, and the two
pieces of work overlap.

## The Self-Scheduling Loop

If at least one interval's worth of gap between every run is wanted, repetition is set
up with `setTimeout`: the callback registers the next one itself, once it has finished
its own work.

This pattern is the measurement stream's first running form. The fake stations' delays
and values are read from a fixed table, so the program gives the same output on every
run.

```js
const SOURCE = {
  A1: { delay: 30, value: 21.4 },
  B2: { delay: 10, value: 19.8 },
  C3: { delay: 20, value: 23.1 },
};

function startStream(stations, interval, callback) {
  let count = 0;
  let id = null;
  let stopped = false;

  function tick() {
    const station = stations[count % stations.length];
    count += 1;
    callback({ order: count, station, value: SOURCE[station].value });
    if (!stopped) id = setTimeout(tick, interval);
  }

  id = setTimeout(tick, interval);

  return function stop() {
    stopped = true;
    clearTimeout(id);
  };
}

const stop = startStream(["A1", "B2", "C3"], 5, (measurement) => {
  console.log(measurement.order, measurement.station, measurement.value);
  if (measurement.order === 4) stop();
});
```

```
1 A1 21.4
2 B2 19.8
3 C3 23.1
4 A1 21.4
```

The `stopped` flag looks unnecessary; it is not. The `stop` function is called *from
inside* the callback, and at that moment the next tick has not yet been registered. If
only `clearTimeout` were called, the call would cancel a timer that has already run,
and `tick` would immediately register a new one right after — the stream would never
actually stop. In self-scheduling loops, cancellation has to be set up together with a
status flag.

## A Pending Timer Keeps the Program Alive

A pending timer tells the host environment "there is work left to do." A server
runtime does not shut down until no pending work remains.

```js
process.on("exit", () => console.log("3 — runtime shutting down"));

setTimeout(() => console.log("2 — pending timer ran"), 50);
console.log("1 — script's synchronous part finished");
```

```
1 — script's synchronous part finished
2 — pending timer ran
3 — runtime shutting down
```

If the same program is cancelled with `clearTimeout`, shutdown happens immediately:

```js
process.on("exit", () => console.log("2 — runtime shutting down"));

const id = setTimeout(() => console.log("this line never runs"), 50);
clearTimeout(id);
console.log("1 — script's synchronous part finished");
```

```
1 — script's synchronous part finished
2 — runtime shutting down
```

This observation will be useful in two places later: an uncancelled timer both delays
the program's shutdown and keeps every value its callback closes over alive in memory.
The second is one of the leak types covered in the memory topic.

## What a Timer Is Not

There are three common misreadings, and all three come from the same source.

`setTimeout(f, 0)` does not mean "run right now"; it means "run once the stack is
empty." This is why it can be used to split a long piece of work in two, but it does
not speed the work up.

A timer is not a stopwatch. The timer delay cannot be relied on to measure the time
elapsed inside a callback; measurement is done with a separate tool.

A timer is not a concurrency tool either. Two callbacks never run at the same time; one
does not start before the other finishes. Waits overlap, computations do not.

## Summary

- A timer's delay is a lower bound; there is no upper bound, and a busy thread freely
  exceeds it.
- Equal-delay timers keep their registration order.
- `setInterval` gives no guarantee of a gap between runs; ticks overlap under
  long-running callbacks.
- A self-scheduling `setTimeout` loop leaves at least one interval's gap between every
  turn; canceling it requires a status flag.
- A pending timer keeps the runtime alive and keeps the values its callback closes over
  in memory.

## Next Step

Timers bound a callback to time; the next step is binding a callback to a *result*.
The next lesson turns the measurement source into a callback interface that can report
an error; it tries reading three stations in sequence and together, then shows this
approach's limits: nesting, repetition of error paths, and the uncertainty that comes
from leaving control to the calling side. Promises will be born as the answer to these
limits.
