In an event-loop runtime, a callback scheduled for 50 milliseconds from now often fires later than that and essentially never earlier. What guarantee do such timers actually give, and how do loops implement them?
answer
- lower bound, not a deadline
- poll timeout = nearest deadline - now
- min-heap vs hierarchical timer wheel
- fixed delay drifts; fixed rate bursts after a stall
- monotonic clock, never wall clock
basics
~20 sA timer promises a lower bound, not a deadline: the callback becomes eligible at the deadline and runs when the loop next gets to it. Delays come from a long-running handler, coarse OS wakeup granularity, and clamping. Loops keep deadlines in a min-heap or timer wheel and set their poll timeout from the nearest one.
solid answer
~60 s**The guarantee is "no earlier than".** A timer makes a callback *eligible* at its deadline; actually running it requires the loop to finish whatever it is doing, notice the expiry, and dispatch it. So actual delay = requested delay + (remaining time of the running handler) + (queue ahead of it) + wakeup granularity. **Implementation.** The loop stores pending deadlines in a structure supporting "nearest deadline" cheaply — a min-heap (O(log n) insert, O(1) peek) or a hierarchical timer wheel (O(1) amortised, better for very many short timers). Before sleeping in the readiness wait it computes the timeout as `nearestDeadline - now`, so it wakes up when a timer is due rather than idling past it. On waking, all expired timers are collected and enqueued. **Practical consequences.** Timers are not a scheduling guarantee, so never use them for real-time deadlines. Repeating timers drift: fixed-delay scheduling accumulates error, fixed-rate does not but can produce bursts after a stall. Always measure against a monotonic clock — wall-clock time can jump backwards or forwards from clock synchronisation.
go deeper
Say a timer means "not before", not "exactly at": the callback is queued at the deadline and runs when the loop is free.
Explain how the loop derives its poll timeout from the nearest deadline, and name the storage options — min-heap for moderate counts, timer wheel for very many short timeouts.
Cover drift in repeating timers, fixed-delay versus fixed-rate semantics with burst behaviour after a stall, monotonic-clock discipline, and using timer overshoot as the loop-lag health metric.
Discuss timer cost at scale — per-connection timeouts, re-arm churn, coalescing into coarse sweeps — and set the expectation that best-effort timers cannot underpin hard latency guarantees without a different scheduling class.
## What a timer really promises Setting a timer means: *do not run this callback before T; run it as soon as convenient afterwards*. It is a lower bound. There is no upper bound at all unless the runtime offers a real-time scheduling class, which general-purpose event loops do not. Actual firing time decomposes as: ``` actual = requested + time left in the handler running at expiry + time to drain work ahead of it in the queue + OS wakeup granularity / scheduler latency + any clamping the runtime applies ``` On a healthy loop this overshoot is sub-millisecond. On a loaded or blocked loop it can be seconds — timer overshoot is in fact one of the cleanest signals of loop health. ## How the loop implements timers A loop has exactly one place where it sleeps: the call that waits for I/O readiness. If it slept indefinitely, timers would never fire until some unrelated I/O woke it. So before sleeping it computes: ``` timeout = max(0, nearestDeadline - monotonicNow()) events = waitForReadiness(timeout) ``` and on return it collects every timer whose deadline has passed and enqueues those callbacks. Data structures: - **Min-heap ordered by deadline.** Peek at the nearest is O(1), insert and remove O(log n). Simple and good for moderate counts. Cancellation is usually lazy: mark the entry dead and skip it when it surfaces, since deleting an arbitrary heap element is awkward. - **Hierarchical timer wheel.** Buckets by time slot, with cascading levels for longer horizons. Insert and expire are O(1) amortised, which matters when you have one or more timers per connection — for example an idle timeout on every socket, with tens of thousands of connections and constant re-arming. The tradeoff is bounded resolution (the tick size) and more complex cancellation. A related trick for the many-timeouts case is that timeouts of equal duration, armed in order, are naturally sorted, so they can live in a simple FIFO list per duration class rather than a heap at all. ## Clamping and granularity Runtimes sometimes impose minimum delays — for instance clamping deeply nested zero-delay timers to a few milliseconds to prevent a self-re-arming timer chain from monopolising the loop. Underneath, the OS wait call itself has finite resolution and the process may not be scheduled the instant its timeout expires, especially on a busy or power-managed machine. So a requested 1 ms is routinely 1–15 ms depending on platform and load. ## Repeating timers and drift Two scheduling policies, and interviews like the distinction: - **Fixed delay**: next deadline = *completion* time + period. Error accumulates: if each run takes 5 ms with a 100 ms period, you get an effective 105 ms period and drift without bound over hours. - **Fixed rate**: next deadline = *previous deadline* + period, so long-run average rate is correct. But after a stall, several deadlines may already be in the past, producing a burst of catch-up executions. Most implementations either fire the burst or coalesce missed ticks; you must decide which your workload wants (metrics flushing: coalesce; billing ticks: catch up). ## Monotonic versus wall clock Compute deadlines from a **monotonic** clock, which only moves forward and is unaffected by clock synchronisation. Wall-clock time can step backwards (correction) or forwards (a big adjustment), which with wall-clock deadlines means either timers that hang for the duration of the backward step or a stampede of instant firings. This is a classic production incident and worth naming. ## Practical guidance - Do not use timers as deadlines for anything hard-real-time; they are best-effort. - Measure the difference between requested and actual firing to derive **loop lag**, the standard health signal for an event-loop service. - Prefer one coarse timer sweeping many entries over one timer per entry when you have very many similar timeouts — fewer timer objects, fewer re-arms. - Always cancel timers you no longer need; forgotten repeating timers keep objects reachable and keep waking the loop, defeating idle power behaviour.
- How would you use timer behaviour to measure the health of an event loop?Schedule a timer for a known short interval and record how much later than the deadline the callback actually runs; that overshoot is the loop lag. Because a timer can only be late when the loop is busy or blocked, the lag distribution — especially its high percentiles — is a direct measure of how long handlers are hogging the loop.
- What breaks when a repeating timer's deadlines are computed from wall-clock time?Clock synchronisation can step the wall clock backwards, in which case deadlines sit in the future and the timer appears to hang for the size of the step, or forwards, in which case many deadlines are suddenly in the past and fire as a burst. Computing deadlines from a monotonic clock avoids both, since it never jumps.
A timer is like telling a busy chef "not before 7:00" rather than booking a table for 7:00. You will not be served early, and if the kitchen is backed up you will be served late.
saying these in an interview costs you the question
- Treating the requested delay as a guaranteed or maximum firing time
- Assuming a zero-delay timer runs before pending continuations or immediately
- Using wall-clock time for deadlines and being surprised by clock adjustments
- Believing timers run on a separate thread and are therefore unaffected by loop load
- Creating one repeating timer per connection at large scale without considering re-arm cost