Skip to content

The Drift Log · Model independence and repeatable builds

Virtual Clock Schedulers for Flake-Free Async Test Fixtures

26 September 2026 · 3 min read · 756 words · established

Two identical crystal prisms casting exactly the same refraction

Eliminate CI test flakiness by decoupling test fixtures from wall-clock timers and advancing asynchronous state with explicit virtual clock ticks.

A test passes locally in 40 milliseconds. On a shared CI runner under contention, it fails one run in twenty. Someone increases a sleep(50) call to sleep(200) to quiet the alert. The suite gets slower, but the failure returns two weeks later when two heavy jobs land on the same host.

This is the standard lifecycle of wall-clock timeouts in integration tests. Code that measures physical time inherits the noise of the host operating system: thread scheduling latency, virtualization jitter, and CPU frequency scaling. When asynchronous assertions rely on wall-clock delays, you are not testing system state; you are betting on scheduler latency.

Eliminating these race conditions requires decoupling your test harness from the operating system clock.

The Cost of Wall-Clock Delays

Most asynchronous systems manage state transitions using timers: backoff retries, debounced inputs, heartbeat checks, and token expiry. In a test environment, developers often write assertions by waiting for real time to elapse:

// Anti-pattern: Racing the kernel scheduler
await dispatcher.publish(event);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(subscriber.receivedEvents).toHaveLength(1);

This pattern breaks for three mechanical reasons:

  1. Jitter dominates short windows. A 10ms timer on an idle developer laptop might fire at 10.4ms. On a virtualized runner with 95% CPU utilization, the kernel may not yield to the test process for 80ms.
  2. Idle time inflates suite duration. If an integration suite has 300 tests that each sleep for 50ms to ensure safety margins, the suite spends 15 seconds doing nothing.
  3. Timer ordering is non-deterministic under load. When three timers are set for 10ms, 15ms, and 20ms, an overloaded event loop can fire all three in a single batch, scrambling the expected sequence of dependent side effects.

Treating time as a physical measurement turns your test suite into a distributed system subject to clock drift and host load. For deterministic software, time must be treated as a discrete state machine.

Advancing State by Explicit Ticks

A virtual clock scheduler replaces native timer functions (setTimeout, setInterval, setImmediate, Date.now) with an in-memory priority queue of callbacks ordered by scheduled execution timestamp.

Instead of waiting for physical hardware interrupts, the test runner explicitly advances the clock:

class VirtualClock {
  private nowMs = 0;
  private queue: Array<{ runAt: number; fn: () => void }> = [];

  now(): number {
    return this.nowMs;
  }

  setTimeout(fn: () => void, delayMs: number): void {
    this.queue.push({ runAt: this.nowMs + delayMs, fn });
    this.queue.sort((a, b) => a.runAt - b.runAt);
  }

  tick(ms: number): void {
    const target = this.nowMs + ms;
    while (this.queue.length > 0 && this.queue[0].runAt <= target) {
      const next = this.queue.shift()!;
      this.nowMs = next.runAt;
      next.fn();
    }
    this.nowMs = target;
  }
}

When time is an integer you increment manually, asynchronous event chains execute sequentially and synchronously from the perspective of the test harness:

// Deterministic: Time moves only when instructed
const clock = new VirtualClock();
const service = new RetryQueue({ clock, timeoutMs: 1000 });

service.enqueue(job);
expect(service.pendingCount()).toBe(1);

// Advance 999ms: Nothing happens yet
clock.tick(999);
expect(service.pendingCount()).toBe(1);

// Advance 1ms: The retry fires immediately, with zero thread sleep
clock.tick(1);
expect(service.pendingCount()).toBe(0);

This makes tests fast—a 24-hour token expiry test executes in sub-millisecond compute time—and completely immune to host load. Whether executed on a single-core container or a 64-core workstation, the execution order is invariant.

Where Virtual Clocks Fail

Virtual schedulers are not a drop-in replacement for every concurrency scenario. There are hard boundaries:

  • True multi-threading and worker processes: If your architecture delegates tasks to OS-level background threads or child processes, advancing a virtual clock in the parent thread will not advance the child’s timers without inter-process synchronization.
  • Unmanaged I/O drivers: Native database drivers (like libpq or raw socket bindings) often implement internal timeouts inside compiled C/Rust extensions. A userland virtual clock cannot intercept these without mocking the transport layer entirely.
  • Unresolved microtasks: In runtimes like Node.js or browsers, microtasks (Promise resolutions) run in a separate queue from macrotasks (Timers). Advancing the clock by 100ms may trigger a timer callback, but if that callback awaits an external Promise that is not tied to the scheduler, the execution loop will suspend until the microtask drains.

When testing components that interface with real OS boundaries, use a dedicated verification harness that isolates I/O channels. For pure business logic, domain state machines, and protocol parsers, however, wall-clock calls should be strictly forbidden.

What to Do on Monday

  1. Grep your test directory for arbitrary delays: Search for setTimeout, sleep, or delay calls used as synchronization barriers in your test files. Every instance is a latent race condition.
  2. Inject a time provider into domain services: Do not allow domain code to call Date.now() or setTimeout() globally. Pass a clock interface via constructor injection, defaulting to system time in production and a virtual clock in fixtures.
  3. Enforce timer control in CI: Use the fake timer utilities built into modern runners (such as Vitest, Jest, or standard Go/Rust clock abstractions) rather than raw sleeps.
  4. Treat flaky tests as state bugs: If an asynchronous test fails only on loaded runners, do not increase the timeout threshold. Pin the scheduler, step the events manually, and verify the state transitions.

Predictable test suites are a prerequisite for reproducible builds. If your assertions depend on how fast a cloud hypervisor allocates CPU cycles to a timer queue, your test suite is measuring environment variance, not code correctness.

Keep reading

Next in the log

The Strategic Master Library · written and reviewed under the house's own epistemic rules: nothing claimed that we cannot show.