Skip to main content
The TimePort is the seam every pacing mechanism in the mobile app is built on: one monotonic clock, one wall clock, and a per-run scheduler for timers and frames.

Quick Start

1

Pick the real adapter at boot

createWebTime() builds a TimePort over the browser’s clock and frame loop. Pass it as time: to createApp.
2

Read a monotonic instant

Call time.nowMs() for delay budgets and elapsed measurements. Call time.epochMs() only when a record needs a real timestamp.
This is what production now does — deps.now is not passed on the real device path, so time.epochMs() runs.
3

Poll or paint through a scheduler

Create one scheduler per run, then use every, setTimer + clearTimer, and requestFrame. Dispose it at teardown.

How It Works

The port carries two clocks and a scheduler factory, and each member has one job.

Why Two Clocks

One clock measures durations, the other stamps records, and collapsing them reintroduces the bug the port was carved to prevent. When the phone’s clock is corrected mid-turn — a common event on a wake from sleep — a wall-clock elapsed measurement can jump backwards. The publish gate’s MAX_HELD_CHARS and the coalescer’s maxDelayMs are both measured against nowMs, so pacing stays sane through a correction. Records that need to be sortable across devices use epochMs.

The app now reads the port, not Date.now()

main.ts now reads the wall clock through TimePort.epochMs and paces the announcer with the monotonic nowMs — until this landed the shipping app read Date.now() for both, and TimePort.epochMs was implemented, conformance-tested, and had zero callers. Every wall-clock read in main.ts was Date.now(): the chat record’s updated, the chats-screen “now” argument to buildChatList, and — crucially — the nowMs passed to the screen-reader announcer.
The announcer’s rate-limit nowMs - lastStreamAtMs >= ANNOUNCE_INTERVAL_MS is an elapsed comparison — exactly the conflation core/src/ports/time.ts says in as many words must not be fed a wall clock: “Conflating them is how a clock correction mid-stream makes a coalescer wait forever.”
Feeding Date.now() into the announcer’s elapsed comparison means a backwards NTP correction mid-answer silences every further streaming announcement until real time catches up with the pre-correction reading — a screen-reader user simply stops being told what the model is saying. The fix: wall clock comes from nowEpochMs() (which resolves to platform.time.epochMs() in production, or deps.now when passed in tests), and the announcer’s nowMs is platform.time.nowMs().

Executable Specification

The port’s first conformance suite runs the same cases against the fake clock and the web adapter, so the two cannot drift.
The fake and the real adapter run the same cases. every’s period was found discarded in the fake in two separate rounds; a test that only ever drives the fake cannot see it a third time.
Each of these cases is also driven by the contract fixture — time_every_fires_once and time_clear_does_nothing — so a case that lost its assertion is caught by the assertion no longer failing when the adapter is deliberately broken. See Adapter Conformance.
If you write a new time adapter (React Native, a different desktop shell), the contract needs a requestAnimationFrame shim in Node — the web adapter targets a browser. Without the shim requestFrame throws ReferenceError and the adapter cannot be contract-tested at all.

Common Patterns

The coalescer’s flush tick is a repeating every, stopped at dispose.
The publish gate arms a one-shot timer and clears it when a frame arrives.

Best Practices

Collapsing them makes elapsed measurements jump when the OS corrects the clock.
A scheduler that outlives its run carries closed gates into the next answer. With the tick now gated, a scheduler reused across runs really can carry a closed gate into the next answer — the risk is no longer theoretical.
A leaked interval keeps the event loop alive for the app’s lifetime.
A gate that stays armed after a cancel forces a paint that no longer belongs.
A TimePort.every fake that never fires the tick makes every pacing test pass for the wrong reason.

Mobile Architecture

The coalescer and publish gate the port paces.

Shell & Adapters

The sibling port every conformance suite pins.