Systems ThinkingApril 18, 2026

A Tiny Mental Model For Debugging Event Loops With Virtual Time

E

Written by

Elena Holos

Last year I got bitten by a bug that “couldn’t possibly happen”: timers were firing, yet the system behaved like they weren’t. It turned out I was using the wrong mental model of time—specifically, how an event loop processes scheduled work.

I ended up building a tiny simulator that makes “virtual time” (time advanced by the program, not by the wall clock) visible. This post is the mental model I wish I’d had earlier, plus a step-by-step code walkthrough.

The mental model that fixed it: virtual time beats wall time

I used to think “a timeout of 10ms means the callback runs ~10ms later.” That’s a wall-clock mental model.

In an event loop, what actually matters is:

  1. There’s a queue of work (callbacks, tasks).
  2. There are scheduled items (timers) with target times.
  3. The loop repeatedly:
    • picks the next ready item,
    • runs it,
    • and only then moves time forward to whatever it needs to run the next timer.

That means the event loop’s “time” is best understood as virtual time: a variable inside the runtime that jumps forward to the next scheduled deadline, rather than continuously tracking real time.

When I switched to that model, the “impossible” bug became predictable.

A concrete failure mode: “I scheduled earlier, so it must run earlier”

Here’s the scenario I built for myself:

  • I schedule two timers:
    • Timer A: fires at t=10
    • Timer B: fires at t=10 as well (same deadline)
  • Timer A’s callback takes a while (it blocks the loop).
  • I expected Timer B to run immediately after Timer A releases the loop, still “at t=10”.

But depending on how the runtime orders same-deadline timers, Timer B might not run first—or it might run later than you’d naively expect—especially if more work gets enqueued during Timer A.

The mental model that helps: same deadline doesn’t mean the same execution order, and execution order affects what gets enqueued before time advances.

The simulator: a virtual-time event loop in ~60 lines

Below is a minimal event loop simulator in Python. It’s not a full async runtime, but it’s enough to make the time behavior obvious.

import heapq from dataclasses import dataclass, field from typing import Callable, List, Optional @dataclass(order=True) class Timer: due: int seq: int callback: Callable[[], None] = field(compare=False) class VirtualEventLoop: def __init__(self): self.now = 0 # virtual time (jumps forward) self._seq = 0 # insertion order for tie-breaking self.timers: List[Timer] = [] def call_later(self, delay_ms: int, callback: Callable[[], None]) -> None: due = self.now + delay_ms self._seq += 1 heapq.heappush(self.timers, Timer(due=due, seq=self._seq, callback=callback)) def run(self) -> None: while self.timers: # Pick the earliest due timer timer = heapq.heappop(self.timers) # Jump virtual time to that timer's due time if timer.due > self.now: self.now = timer.due # Run it (this may schedule more timers) timer.callback() # --- Demo: observe ordering at identical deadlines --- loop = VirtualEventLoop() events: List[str] = [] def busy_work(name: str, duration_ms: int): # This simulates a callback that monopolizes the loop for "duration_ms". # In real runtimes, blocking code delays *everything*. events.append(f"{name}: start at t={loop.now}") loop.now += duration_ms # advance virtual time to model blocking events.append(f"{name}: end at t={loop.now}") def make_callback(name: str, delay: int, duration: int): def cb(): busy_work(name, duration) events.append(f"{name}: done at t={loop.now}") return cb # Schedule two timers with the same due time loop.call_later(10, make_callback("A", delay=10, duration=7)) loop.call_later(10, make_callback("B", delay=10, duration=0)) loop.run() print("\n".join(events))

Walkthrough, block by block

Timer and the heap

  • I model timers as (due, seq, callback).
  • I use heapq so the loop can always pick the timer with the smallest due time.
  • seq is an incrementing sequence number so when two timers have the same due, the one scheduled first runs first. (This is a simplified tie-breaker—but it’s crucial for reproducing surprising behavior.)

self.now is virtual time

  • self.now is not wall clock time. It’s the loop’s internal “timeline.”
  • In run(), I jump self.now forward to the next timer’s due.

busy_work simulates blocking

  • Inside Timer A’s callback, I add duration_ms to loop.now.
  • This is a simplified stand-in for “the event loop is busy executing code and can’t service other callbacks.”

What happens when I run it

With Timer A “blocking” longer, I get an output pattern like:

  • A starts at t=10
  • A ends at t=17 (virtual time advanced by blocking)
  • B then runs (but it’s no longer at t=10 in virtual time)

That last part is the key mental model change: even if a timer is due at t=10, if the loop is blocked, virtual time may advance past t=10 before the callback gets a chance to run.

Why this matches real debugging pain

In real systems, the event loop is juggling:

  • timers (scheduled callbacks),
  • I/O readiness,
  • microtasks (small “run now” jobs),
  • and userland code that can block.

If you debug assuming “due time == execution time,” you’ll chase ghosts:

  • logs show the timer was “scheduled for 10ms”
  • but the callback appears “late”
  • and ordering feels inconsistent

The virtual-time model says: execution time is a function of when the loop becomes free and what’s in the queue at that moment, not just the nominal due time.

A subtle twist: same-deadline ordering can still matter

Even in my simplified simulator, tie-breaking uses insertion order (seq). Some runtimes may differ:

  • they may preserve insertion order,
  • or they may reorder timers based on internal bookkeeping,
  • or they may group timers and flush them in batches.

That means two timers with the same due timestamp can still produce different order—and because callbacks can enqueue additional work, the differences cascade.

In mental-model terms: “time” is only half the story; “queue semantics” decide the rest.

Systems thinking tie-in: what “time” controls in the whole system

Once I started thinking this way, I noticed the same pattern everywhere:

  • Architecture trade-off: more concurrency can reduce blocking, but increases queue interactions.
  • Incident culture: logs that only record “scheduled at” miss the real causal chain; they should also capture “executed at” and “blocked time.”
  • Tech philosophy: treat the runtime scheduler as part of your system, not a black box.

This is systems thinking in miniature: the “event loop” is a component, time is a shared resource, and callbacks are processes competing for execution.

Conclusion

I learned to debug timer and event-loop weirdness by switching mental models from “wall time” to virtual time plus queue semantics. The key insight is that a callback’s nominal due time does not guarantee its execution time—execution depends on when the loop becomes free and how same-deadline work is ordered.