Skip to content
academia.sh

Lesson 03 / 17

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.

Contents

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.

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.

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:

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:

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.

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.

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:

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close