@zakkster/lite-time
v1.5.2
Published
Reactive, drift-corrected wall-clock cadence for @zakkster/lite-signal. One 1s heartbeat, zero-GC relativeTime / countdown / every, deterministic for tests and SSR. Not a date library -- Intl does formatting, you bring the dates.
Maintainers
Readme
@zakkster/lite-time
Reactive, drift-corrected wall-clock cadence for
@zakkster/lite-signal. One 1 s heartbeat, zero-GC steady state, deterministic for tests and SSR. Not a date library --Intldoes formatting, you bring the dates.
The reactive clock the ecosystem was missing
Every UI that shows a chat list, an order feed, a leaderboard, or a "posted N minutes ago" badge needs relative-time strings that re-render on a schedule. Do it the textbook way -- setInterval(updateAll, 1000) per cell, a fresh Intl.RelativeTimeFormat each tick, no cutoff -- and it works fine at low scale, then quietly becomes a GC problem the moment the list grows, drifts off the :00 boundary, and pins the Node event loop in your tests. lite-time is the one primitive that gives a @zakkster/lite-signal graph a single scheduler-owned heartbeat, a display-stable cutoff so 100 cells cost about the same as 1, and a fully injectable clock so tests and SSR are deterministic by construction.
npm install @zakkster/lite-time @zakkster/lite-signalimport { effect } from "@zakkster/lite-signal";
import { relativeTime, countdown, every } from "@zakkster/lite-time";
const posted = relativeTime(() => order.createdAt); // "3 minutes ago"
const remain = countdown(order.deliveryDeadline); // ms until deadline, clamps at 0
effect(() => element.textContent = posted()); // updates only when the display changes
every(60_000, () => refreshDigestPanel()); // drift-corrected, boundary-alignedOne heartbeat ticks the whole graph. Effects re-render only when the visible text changes. The clock is injectable for tests and SSR, so the same code path runs whether time comes from Date.now or from a value you script by hand.
Headline: at 100 relativeTime cells with the cutoff held, lite-time's steady allocation is deterministic and bounded -- 48.35 B/tick (967 KB transient over 20,000 ticks, retaining ~2.4 KB), only about 6x the per-tick cost of a single cell (7.77 B/tick) rather than 100x, because the packed-SMI cutoff suppresses almost every Intl.format. The same workload hand-rolled with a fresh Intl.RelativeTimeFormat per cell per beat allocates 100 short-lived formatters every tick -- the major-GC pressure the cutoff removes. (Re-measured for 1.5.2 on node v26.3.1, arm64, v8 14.6.202.34-node.20; full board below.)
Table of contents
- Why this exists
- What you get
- How the heartbeat and the cutoff work
- API reference
- Determinism: virtual clock for tests & SSR
- Composability
- Zero-GC design notes
- Benchmarks
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
- License
Why this exists
lite-time was built under four constraints simultaneously:
- One heartbeat for the whole graph. Not one timer per cell. The period (1 s) is never a caller's business -- every reason to "tune" it belongs to a different primitive (sub-second -> frame timing; periodic tasks ->
every(); simulated time -> a scaled accumulator). There is one heartbeat because the period is a wall-clock semantic, not a knob. - Zero allocation in steady state. A 1000-row leaderboard refreshing every second cannot allocate 1000 strings per tick. The packed-SMI cutoff means
Intl.formatruns only on actual display changes. - Deterministic for tests and SSR. No
vi.useFakeTimers()shenanigans, no flakyawait sleep().setTimeSource+tick()give you a fully scriptable virtual clock that drives the same code path as the real heartbeat. - Drift-corrected and self-healing. Each beat schedules the next at
period - (now % period), landing on the:00boundary forever. No catch-up storm after a refocus. Boundary-alignedevery()is the same recipe on its own timer.
It is not a date library. Intl.RelativeTimeFormat does the formatting (no locale data shipped). Date / Temporal / a raw epoch-ms number all coerce. Everything else is reactivity over time.
What you get
now-- read-only reactive epoch-ms signal, force-propagating on every beat.clock(resolutionMs?)-- read-only reactive epoch-ms at a coarser resolution (e.g. once a minute); zero-allocation cutoff.relativeTime(target, opts?)-- auto-updating "3 minutes ago" string. Display-stable cutoff.countdown(target)-- remaining ms, clamped at 0.onElapsed(target, fn)-- fire-once callback when a countdown hits zero.every(ms, fn)-- drift-corrected boundary-aligned interval on its own timer.everyVisible(ms, fn, opts?)-- anevery()that auto-pauses while the tab is hidden and catches up once on resume.nowInstant()-- opt-inTemporal.Instantview ofnow(null where Temporal is absent).startClock()/stopClock()/tick()-- heartbeat lifecycle; auto-parked (1.4.0), the beat runs only while something is watching (or after an explicitstartClock()).setTimeSource(fn?)-- replace the epoch-ms source (tests, SSR, replays, external master clock).setClock(epochMs?)/advanceClock(deltaMs)-- scriptable virtual clock over that seam; pin a time and step it; fail-closed doors (1.5.1).
Runs anywhere modern JS runs: pure ES2020 + Intl.RelativeTimeFormat (Baseline 2020) + optional Temporal (gracefully null where absent). Verified targets: Chrome / Edge / Firefox (last 2 majors), Safari 14+, Node.js 18+, Bun, Deno, Cloudflare Workers, and Twitch Extensions (1 MB / 3 s budget). ESM-only -- no CommonJS build; modern bundlers handle this, legacy consumers can use a wrapper. Full type definitions ship in Time.d.ts and are referenced from package.json; every public symbol has JSDoc.
How the heartbeat and the cutoff work
flowchart TB
subgraph Source
TS["timeSource()<br/>(Date.now by default)"]
end
subgraph Scheduler
HB["beat()<br/>boundary-aligned setTimeout<br/>HEARTBEAT = 1000 ms"]
HB -->|"sets each beat"| NOW
end
subgraph Reactive
NOW(("now signal<br/>equals: () => false"))
PK[["packed computed<br/>(value << 2) | unitCode"]]
FMT[["format computed<br/>rtf.format()"]]
USR{user effect<br/>or computed}
NOW --> PK --> FMT --> USR
end
subgraph Independent
EV["every(ms, fn)<br/>own setTimeout<br/>own drift-correct loop"]
end
TS -.->|"timeSource()"| HB
TS -.->|"timeSource()"| EVOne scheduler-owned heartbeat. The now signal is force-propagating (equals: () => false) so every beat reaches the graph; the inner packed-SMI computed produces an integer that only changes when the visible text changes; the outer format computed therefore halts on lite-signal's Object.is cutoff and Intl.format runs only when something user-visible would actually move. every() lives on its own setTimeout loop with its own drift correction -- fully independent of the heartbeat, works while the clock is parked, is not driven by tick(), and stopClock() does not stop it.
How a beat propagates.
sequenceDiagram
participant T as setTimeout
participant B as beat()
participant N as now signal
participant P as packed computed
participant F as format computed
participant E as user effect
T->>B: fire at next :00 boundary
B->>B: _now.set(timeSource())
B->>N: bump version (equals: () => false)
N->>P: mark dirty, schedule re-evaluation
P->>P: pack(target - now()) -> SMI
alt SMI unchanged (Object.is true)
P-->>F: no propagation -- cutoff holds
Note over F,E: format() and effect SKIPPED.<br/>Zero Intl alloc, zero string alloc.
else SMI changed (display moves)
P->>F: mark dirty
F->>F: rtf.format(value, unit) -> new string
F->>E: mark dirty
E->>E: re-run user body
end
B->>T: schedule next: HEARTBEAT - (now % HEARTBEAT)The equals: () => false on now is the "fixed-rate trap" lesson from lite-raf: a clock must re-tick dependents every beat even if two reads coincide. The packed-SMI inside relativeTime is what stops the chain when there is nothing to render.
The cutoff: how 100 cells cost the same as 1. The packed-SMI is a one-integer encoding of (value, unit):
unit = packed & 0b111 -> 0=second, 1=minute, 2=hour, 3=day, 4=week, 5=month, 6=year
value = packed >> 3 -> sign-preserving SMIEvery beat: (1) the now signal force-propagates -- it has to, since V8 is allowed to elide set(x) when x === old, but equals: () => false defeats this; (2) the packed computed runs and produces (value << 3) | unitCode; (3) lite-signal compares the new SMI to the cached one with Object.is -- two SMIs encoding the same (value, unit) are bitwise identical, so the cutoff holds; (4) the outer format computed and the user effect do not re-run. Within the same minute, every sub-second beat hits step 3 and stops. A list of 100 cells displaying "N minutes ago" allocates one format string per minute, not 100 per second.
Why a packed-SMI cutoff instead of comparing strings? Three reasons: (1) string comparison would allocate the string first -- the cutoff would land after the cost; (2) Object.is on an SMI is a bitwise compare; (3) the SMI encodes (value, unit) together, so the cutoff is exact -- a beat crossing "59 seconds ago" to "1 minute ago" changes both the value and the unit, and both must mismatch for the cutoff to release, which they do. Static formatters are cached per (locale, numeric, style) triple; the Intl.RelativeTimeFormat constructor never runs in steady state.
API reference
import {
now, clock, relativeTime, countdown, onElapsed,
every, everyVisible, nowInstant,
startClock, stopClock, tick,
setTimeSource, setClock, advanceClock,
} from "@zakkster/lite-time";now
now() // tracked read
now.peek() // untracked read
const off = now.subscribe(value => { /* value-now and on every beat */ });
off();Read-only handle. No .set -- only the scheduler advances it. Forces propagation each beat (the cutoffs downstream decide what actually re-runs). A first now() read or subscribe(fn) wakes the parked heartbeat (0->1 observer edge); now.peek() alone never links and never wakes, and while the beat is parked it reads through to the live source (fresh, and the virtual epoch under setClock) rather than the last beat's stale value.
clock(resolutionMs?)
const minute = clock(60_000); // read-only reactive epoch-ms, changes once a minute
minute() // tracked read; floored to the current minute boundary
minute.peek(); // untracked
const off = minute.subscribe(ms => renderClock(new Date(ms)));A read-only reactive epoch-ms quantized down to the resolution boundary, so clock(60_000)() is the start of the current minute. It recomputes on every 1 s beat but only propagates when the quantized value changes -- the Object.is cutoff makes a coarse clock zero-allocation in steady state, the same trick relativeTime uses.
It coarsens only: you cannot go finer than the 1 s heartbeat, because sub-second cadence is a frame concern (lite-raf's frameTime), by deliberate design. clock(1000) returns the now signal itself; a resolution below 1000 simply tracks the 1 s beat. resolutionMs must be finite and > 0 or it throws RangeError. Cells are memoized one per distinct resolution -- clock(60000) === clock(60000) is an identity law -- created ownerless so a first call inside an effect cannot let the owner cascade-dispose the shared cell; they are shared infrastructure, never disposed, and dispose() on a clock cell is a silent no-op. Reading a non-1s clock cell pins the heartbeat armed until stopClock().
relativeTime(target, opts?)
const t = relativeTime(target, {
locale?: string | string[], // forwarded to Intl
numeric?: "auto" | "always", // default "auto"
style?: "long" | "short" | "narrow" // default "long"
maxUnit?: "day" | "week" | "month" | "year" // display ceiling; default "year" (since 1.5.0)
});
effect(() => label.textContent = t());Since 1.5.0 the unit ladder runs all the way up -- a year-old timestamp renders "last year", not "400 days ago" (that was LT-08). Unit selection uses fixed civil-average divisors, documented approximations -- never calendar math:
| Unit | Chosen while | Divisor | | ------ | ---------------------- | ----------------------- | | second | < 60 s | 1 s | | minute | < 60 min | 60 s | | hour | < 24 h | 3,600 s | | day | < 7 d | 86,400 s | | week | < ~4.35 w (one month) | 604,800 s (7 d) | | month | < 12 mo | 2,630,016 s (30.44 d) | | year | otherwise | 31,557,600 s (365.25 d) |
(Thresholds apply to the rounded value, so displays flip at the half-way point --
"6 days ago" becomes "last week" at 6.5 days. The exact +-1 ms boundary constants are
pinned in decisions/0005-units.md and the torture t5 oracle.) Approximation honesty:
30 days renders "4 weeks ago" though a calendar would call it a month, and ~31.5
days renders "1 month ago" though a calendar would call it 4.5 weeks. If you need
calendar-correct months ("was it the 31st?"), that is Temporal's job -- pass a
Temporal-derived reactive target instead; lite-time will not grow calendar math.
maxUnit caps the ladder: { maxUnit: "day" } reproduces the pre-1.5.0 display
byte-for-byte ("400 days ago" again). Only "day" | "week" | "month" | "year" exist --
lower caps would overflow the packed 32-bit field at the range door and were
deliberately rejected; anything else throws a RangeError at the call site rather than
silently changing your display. maxUnit is not part of the formatter cache key --
it drives unit selection, not the formatter.
target may be:
- a number (epoch ms),
- a
Date, - a
Temporal.Instant/Temporal.ZonedDateTime(structurally typed -- no hard dep), - a function returning any of the above (reactive -- re-read each beat).
A valid instant is in the inclusive range [-8.64e15, 8.64e15] (the bound Date
enforces). Strings are NOT instants and are rejected -- lite-time is not a date parser.
+-Infinity are accepted as directions: +Infinity = "never", -Infinity =
"elapsed forever". Static invalid targets (null, undefined, NaN, an Invalid
Date, a string, non-coercibles) throw a TypeError at the call site -- no silent
"55 years ago", the "1970 trap" is impossible. An invalid reactive read
(relativeTime(() => order?.createdAt) while order is loading) degrades to "" and
self-heals once the target becomes valid; +-Infinity also render "" (no display for
a direction). It never throws into the shared heartbeat flush.
Formatters are cached per (locale, numeric, style) triple; two callers with the same
options share one immutable Intl.RelativeTimeFormat. locale may be a string or an
array of strings; any other value is coerced to its string tag for the cache key and
handed to Intl as-is (Intl validates on construction). The returned getter carries a
composite, idempotent .dispose() that frees both inner cells; after dispose reads
return undefined (the peer's gen-guarded stale-handle contract). lite-signal-native
apps may instead create inside an owner scope and skip dispose (decision
0003-lifecycle.md).
countdown(target)
const remain = countdown(target); // ms remaining, clamped at 0
effect(() => bar.style.width = `${(1 - remain() / total) * 100}%`);Remaining ms until target, same target types and law as relativeTime. A valid
target gives max(0, remaining); +Infinity ("never") gives Infinity; -Infinity
("elapsed forever") and any invalid reactive read give 0. The invalid-0 is a
display degradation only -- elapsed-ness is onElapsed's job, never this clamp.
Static invalid throws TypeError. Idempotent .dispose() frees its one cell; disposed
reads return undefined.
onElapsed(target, fn)
const stop = onElapsed(deliveryDeadline, () => showLatePopup());Fires fn once when target elapses, then self-disposes; returns stop(). Built on
lite-signal's when -- no extra dependency, no Promise allocation. It survives an
invalid stretch: a not-yet-loaded reactive target does not fire at mount and stays
alive to fire at the real deadline once it heals. -Infinity fires once immediately;
+Infinity never fires; a target already in the past fires synchronously on
registration. Static invalid throws. The returned stop() frees the internal computed
on every exit path -- external stop, self-dispose after fire, double stop, and a
registration-time throw -- idempotent.
every(ms, fn)
const stop = every(60_000, () => refreshHourlyDigest());Run fn on each ms wall-clock boundary, drift-corrected, on its own timer. First
fire is at the first ms boundary, not immediate (call fn() yourself for immediate).
ms must be finite and > 0 -- every(0, ...), every(NaN, ...), every(Infinity,
...) throw RangeError rather than thrash the event loop with setTimeout(..., 0).
It is independent of the heartbeat: calling every(60_000, ...) does not start the
global clock and keeps working while the clock is parked via stopClock(); conversely
stopClock() does not stop an every() -- hold its returned stop() and call it
yourself. It schedules on the real event loop, so unlike now / relativeTime /
countdown it is not driven by tick() / setTimeSource; to unit-test interval
logic deterministically, test your callback directly or use your runner's fake timers.
Its timer is unref'd, so a forgotten every() will not hang a test process.
everyVisible(ms, fn, opts?)
const stop = everyVisible(1000, () => repaintClock()); // pauses while the tab is hidden
const stop2 = everyVisible(60_000, sync, { runOnResume: false }); // no catch-up on resumeLike every(), but it suspends its timer while document.visibilityState === "hidden"
and resumes on becoming visible. If at least one boundary elapsed while hidden, fn
runs once on resume -- a single catch-up so a stale clock snaps to the right value,
never a backlog storm of every missed tick -- unless you pass { runOnResume: false }.
With no DOM (SSR / Node) it degrades to a plain every(). The returned stop() clears
the timer and detaches the visibility listener (its only retained handle). Same
own-timer, real-event-loop, unref'd, RangeError semantics as every().
nowInstant()
const inst = nowInstant(); // Temporal.Instant | nullReactive Temporal.Instant view of now. Allocates one Instant per beat (opt-in,
unlike the raw now() SMI -- use now() for hot paths). Returns null in runtimes
without Temporal. Reading it links the immortal internal cell, so it wakes the
heartbeat and pins it armed until stopClock() -- same story as the memoized clock
cells (decision 0004-park.md).
startClock() / stopClock() / tick()
startClock(); // idempotent; HOLD the beat armed even with no observers (warm start)
stopClock(); // HOLD the beat parked; observers do not re-arm it; primitives stay valid
tick(); // advance one beat by hand (never arms a timer)The heartbeat is auto-parked by default (1.4.0, decision 0004-park.md): armed iff
something is watching or it was explicitly started. startClock() / stopClock() are a
sticky two-way override latch (fresh-module state is auto; there is no public
return-to-auto). startClock() arms and holds armed regardless of observers
(warm-before-first-paint), idempotent. stopClock() parks and holds parked -- an
observer arriving does not re-arm until startClock(); primitives stay valid and values
still move under tick/setClock/advanceClock. tick() advances one beat by hand
for deterministic tests, SSR, or syncing to another loop -- it never arms a timer and
never touches the latch.
setTimeSource(fn?)
setTimeSource(() => fakeClock); // install
setTimeSource(); // restore Date.nowReplace the epoch-ms source (default Date.now). Pass a function to install it, or no
argument / undefined / null to restore Date.now (the documented absence idiom).
Fail closed since 1.5.1 (decision 0006-time-sources.md): anything else throws a
TypeError rather than silently coercing to Date.now. The source is the single place
wall-clock time enters the library -- every read of Date.now goes through this seam,
which is why virtual clocks, replays, and server-master sync all attach here.
setClock(epochMs?) / advanceClock(deltaMs)
stopClock(); // optional; you drive time by hand
setClock(0); // pin wall-clock to a fixed epoch; now() === 0
advanceClock(90_000); // step it 90 s; returns the new epoch (90000)
advanceClock(-1000); // negative deltas allowed (time travel)
setClock(); // restore Date.nowA scriptable virtual clock built directly on the setTimeSource / tick() seam.
setClock(epochMs) pins time to a fixed epoch (within +-8.64e15) and propagates one
beat; advanceClock(deltaMs) steps a pinned clock and propagates, returning the new
epoch (always === now.peek()). Both make now-derived primitives -- relativeTime,
countdown, onElapsed, clock, nowInstant -- update synchronously with no real
timers: no real heartbeat timer exists while the clock is pinned, so determinism is by
construction and stopClock() is optional hygiene.
The doors fail closed with TypeError (1.5.1, decision 0006-time-sources.md):
setClock() / setClock(null) restore Date.now, a valid epoch pins, anything else
throws; advanceClock throws on a non-finite delta AND when no clock is pinned (call
setClock(Date.now()) first to adopt real time -- the old implicit adopt is gone). The
independent interval timers (every / everyVisible) schedule on the real event loop
and are not virtualized -- they keep their own real timers regardless of the pin, so
stop them via their returned handles. The two seams are one state machine over three
states (REAL / CUSTOM / VIRTUAL); the full event table lives in
TESTING-AND-SSR.md.
Constants
| Constant | Value / meaning | Notes |
| ----------------------- | --------------- | ----- |
| HEARTBEAT | 1000 ms | Fixed beat period (Time.js:103). Not configurable -- sub-second cadence is lite-raf's job. |
| valid-instant range | +-8.64e15 | Exposed as MAX_INSTANT; the inclusive bound Date itself enforces. |
| unit thresholds | second / minute / hour / day / week / month / year | Ladder codes 0..6; thresholds apply to the rounded value. |
| unit divisors | 604800 / 2630016 / 31557600 s | Fixed civil-average divisors (7 d / 30.44 d / 365.25 d) -- documented approximations, never calendar math (decisions/0005-units.md). |
| VERSION | string | Three-place sync law: the VERSION const, package.json's version, and llms.txt's header must agree, bumped in the same commit or not at all. |
__test -- unstable, test-only
__test is a test-only seam of pure internals -- NOT part of the public contract.
It exists for the test architecture (the numbered suites and the torture harness reach
internal machinery through it) and promises nothing about stability; it may change or
vanish in any release. Do not import it in application code. Its members are
alignDelay, pack, UNITS, getRTF, MAX_INSTANT, setHold(h), and
sourceState(). The container is frozen (shallow) since 1.5.2 -- Object.freeze
makes the reference immutable, but the members themselves are not deep-frozen.
sourceState() allocates a fresh object on every call, so it must never be called inside
a measured (zero-allocation) region.
Determinism: virtual clock for tests & SSR
lite-time has no hidden Date.now() scattered through the hot path. Every read goes through setTimeSource, and the clock can be advanced by hand with tick(). Together they give you a fully scriptable virtual clock that drives the same code path as the real heartbeat -- determinism is by construction, not a workaround: while a clock is pinned, zero real timers exist.
import assert from "node:assert/strict";
import { effect } from "@zakkster/lite-signal";
import { relativeTime, setTimeSource, tick, stopClock } from "@zakkster/lite-time";
let clock = Date.parse("2030-01-01T00:00:00Z");
setTimeSource(() => clock);
stopClock(); // hold the beat parked; you drive it
const rt = relativeTime(() => clock - 3 * 60_000);
let text; effect(() => { text = rt(); });
assert.equal(text, "3 minutes ago");
clock += 90_000; tick(); // jump 90 s
assert.equal(text, "4 minutes ago"); // updated deterministically, no real time passed
setTimeSource(); // restore in teardownNo vi.useFakeTimers(). No microtasks. No real time passes during the test. The assertion runs synchronously after tick(). The same script reads more directly with the setClock / advanceClock convenience, which pins the source and ticks for you:
import { relativeTime, setClock, advanceClock, setTimeSource } from "@zakkster/lite-time";
setClock(Date.parse("2030-01-01T00:00:00Z")); // pin + propagate; a pinned clock arms no real timer
const rt = relativeTime(() => Date.parse("2030-01-01T00:00:00Z") - 3 * 60_000);
let text; effect(() => { text = rt(); });
assert.equal(text, "3 minutes ago");
advanceClock(90_000); // step 90 s + propagate
assert.equal(text, "4 minutes ago");
setClock(); // restore Date.now in teardownSSR / hydration
On the server you render once and exit -- you don't want a heartbeat at all. Pin the source to the request timestamp, read the value, render:
setTimeSource(() => requestTimestamp);
const html = renderRelative(relativeTime(() => order.createdAt).peek?.()
?? relativeTime(() => order.createdAt)());Because the heartbeat's timers are unref'd, an SSR process that never calls stopClock() still exits cleanly -- the clock will not pin the Node event loop, and since 1.4.0 creation alone arms nothing: an SSR render arms the single unref'd beat only once something actually reads or observes a cell. If your process hangs, it is almost certainly not lite-time: a unref'd timer cannot pin the loop, so look for a ref'd timer elsewhere -- a database driver, a tracing exporter, a test runner. The import-and-use path is proven to exit cleanly within 4 seconds by a child-process test in the suite. In browsers, setTimeout returns a number with no .unref, so the guard is a no-op there -- but browsers have no event loop to pin either. Perfectly isomorphic.
On the client, hydrate normally. The first read or observation of a relativeTime / countdown wakes a real 1-second heartbeat off the client's Date.now, so the rendered "2 minutes ago" begins ticking forward from the hydrated value. If your server and client clocks differ, the first client beat reconciles the display. For the full recipe set, see TESTING-AND-SSR.md.
Composability
The whole surface is one heartbeat feeding a lite-signal graph, so a real feature composes several primitives under a single owner and disposes them together. Here an order card wires a live "posted" label, a progress bar, a fire-once late popup, and a periodic background refresh -- then tears all of it down with one call:
import { createRoot, effect } from "@zakkster/lite-signal";
import { relativeTime, countdown, onElapsed, every } from "@zakkster/lite-time";
function mountOrderCard(el, order) {
return createRoot((dispose) => {
const posted = relativeTime(() => order.createdAt); // "3 minutes ago"
const remain = countdown(order.deadline); // ms left, clamped at 0
effect(() => { el.time.textContent = posted(); }); // repaints only on display change
effect(() => {
el.bar.style.width = `${(1 - remain() / order.total) * 100}%`;
});
onElapsed(order.deadline, () => showLatePopup(order)); // fires once, then self-disposes
const stop = every(60_000, () => refreshOrderPanel(order)); // own timer, drift-corrected
return () => { stop(); dispose(); }; // one teardown frees every cell + the interval
});
}Everything above shares the one heartbeat; each cell is gated by its own cutoff, so the card costs about the same whether it is alone or one of a thousand. Two shorter recipes:
Chat-list "posted N minutes ago" -- one cell per row, all on the single beat:
const mountTimestamp = (el, message) =>
effect(() => { el.textContent = relativeTime(() => message.createdAt)(); });Twitch overlay on a server-time master clock -- correct every cell at once by moving the source:
import { setTimeSource } from "@zakkster/lite-time";
let offsetMs = 0;
Twitch.ext.onAuthorized(() => {
fetch("/server-time").then(async (r) => { offsetMs = (await r.json()).now - Date.now(); });
});
setTimeSource(() => Date.now() + offsetMs); // every relativeTime now uses server-corrected timeFor a deterministic node --test recipe (setTimeSource + tick(), or setClock + advanceClock), see TESTING-AND-SSR.md.
Zero-GC design notes
relativeTime pre-allocates its two computeds and shares the formatter and clock caches at construction; afterward a beat that does not move the display does integer arithmetic and one Object.is compare, and stops. Intl.format -- the only string allocation -- runs solely when the packed SMI changes.
| Operation | Steady-state allocation |
| ------------------------------------------- | ----------------------- |
| steady tick, cutoff holds (display stable) | ~7.8 B/tick transient, ~0 retained (bench B; same as a bare heartbeat beat) |
| display change (Intl.format fires once) | ~6.9 B/tick transient (bench C; one Intl.format string per change) |
| relativeTime creation | pool-flat over 4096 create/dispose cycles (torture lifecycle tier; tracker.size() -> 0) |
| dispose() | idempotent; 0 retained over 4096 cycles (torture; tracker.size() -> 0) |
Retention. The shared caches are infrastructure, not per-caller state: now, nowInstant's Instant cell, the Intl.RelativeTimeFormat cache, and the memoized clock(res) cells are never disposed (decision 0003-lifecycle.md). Per-caller cells (relativeTime / countdown) carry a composite idempotent dispose(), or are cascade-freed by their owner scope; onElapsed / every / everyVisible return stop(), which is their disposal. Auto-park rides on this: disposing your cells (or letting the owner cascade do it) unlinks the heartbeat's signal, and the last unlink parks the beat -- an idle graph holds zero timers. The torture harness (@zakkster/lite-leak + @zakkster/lite-gc-profiler, under --expose-gc) proves tracker.size() returns to 0 and 0 major GCs across the full loop.
No microtask scheduling. lite-signal answers signal.set() synchronously by default, so when a beat fires every dependent re-renders in the same call stack -- lite-time inherits that property by construction. No promise machinery, no flushing semantics to memorize. (The peer's opt-in effect scheduler can defer if you ask it to; the default path does not.)
Background tabs. The boundary-aligned setTimeout accumulates at most one missed beat (browsers throttle background timers to >= 1 s anyway). When the tab refocuses, the next beat lands on the next :00 boundary -- no catch-up storm, no flicker.
Benchmarks
Honest numbers, against the same workload. All measurements: node v26.3.1, arm64, v8 14.6.202.34-node.20, --expose-gc, warm-up 2,000 ticks then measure 200,000 (single-cell) or 20,000 (100-cell) ticks. heap delta is transient (BEFORE GC) -- the metric that drives major-GC pause frequency. Retained is post-GC; should hover at 0. B/tick is transient / N. The naive-at-scale transient (F) is GC-noise-dominated -- the naive path allocates so heavily that minor GCs fire mid-window, so its transient varies run to run while lite-time's (E) is deterministic; the real naive cost surfaces as major-GC pauses, not this delta.
Single-cell
| Scenario | heap delta (transient) | Retained | B/tick |
|---|---|---|---|
| A -- pure heartbeat: now -> effect | 1.63 MB | 20.9 KB | 8.13 |
| B -- relativeTime, display stable (cutoff holds) | 1.55 MB | 22.9 KB | 7.77 |
| C -- relativeTime, display changes every tick (Intl.format every beat) | 1.38 MB | 2.7 KB | 6.88 |
| D -- naive baseline (fresh Intl per tick, no cutoff) | 404.7 KB | -7.7 KB | 2.02 |
At one cell, the naive approach is actually slightly leaner per tick -- V8's nursery handles the Intl churn fine and the reactive graph carries a small fixed overhead. The honest take: at low scale, hand-rolling is OK. The interesting numbers are at scale.
At scale -- 100 cells, display stable, same workload
| Scenario | heap delta (transient) | Retained | B/tick | |---|---|---|---| | E -- lite-time × 100 cells | 967.0 KB | 2.4 KB | 48.35 | | F -- naive × 100 cells | 1.66 MB (noisy) | -56 B | 82.88 (noisy) |
At scale, the cutoff almost completely eliminates the per-cell penalty, driven mostly by suppressing the overwhelming majority of Intl.format calls: lite-time × 100 cells is deterministic at 48.35 B/tick (967.0 KB every run, retaining 2.4 KB) -- only ~6x a single stable cell (7.77 B/tick), not 100x. The naive path allocates a fresh formatter per cell per tick, so its transient heap delta is GC-noise-dominated and its true cost lands as major-GC pauses.
Run it yourself:
npm run bench # prints the tables above + a node/arch/v8 fingerprint lineThe harness is in bench/bench.mjs. It drives time via setTimeSource + tick(), so the path under measurement is the same path the real heartbeat takes -- no synthetic substitute (every effect's output is captured into a closure variable V8 cannot prove dead). Numbers are printed with a runtime fingerprint: node v26.3.1, arm64, v8 14.6.202.34-node.20 (re-measured for v1.5.2). A given row is comparable only against the same runtime; reproduce on your own hardware with npm run bench.
Design decisions worth knowing
- A throw in shared timer infrastructure must not kill the clock (
decisions/0001-throw-containment.md). A missingfinallyonce let a user callback or a throwing getter escape through the shared heartbeat flush and stop it for every subscriber. The beat is nowtry/finally-contained: an invalid reactive read degrades to""and self-heals, and the beat re-arms regardless. - Invalid is not elapsed, and Infinity is not "today" (
decisions/0002-target-law.md). A static invalid target throws aTypeErrorat the call site (no silent "55 years ago"); an invalid reactive read degrades to a display placeholder without poisoning the graph;+-Infinityare accepted as directions ("never" / "elapsed forever"), never as instants. - A
dispose()you can call, and shared cells you never should (decisions/0003-lifecycle.md). Per-caller primitives return a composite, idempotentdispose()(or cascade-free with their owner); the shared infrastructure --now, the formatter cache, the memoizedclockcells -- is bounded and never disposed. - The heartbeat is armed only while something is watching (
decisions/0004-park.md). Creation arms nothing; a first read/observation wakes the beat and the last unlink parks it, so an idle graph holds zero timers.startClock/stopClockare the sticky override. - Full unit range by default,
maxUnitopt-out (decisions/0005-units.md). The ladder runs to "year" with fixed civil-average divisors (documented approximations, never calendar math);{ maxUnit: "day" }restores the pre-1.5.0 display, and lower caps are rejected because their values would wrap the packed 32-bit field. - One pin, two truths that move together (
decisions/0006-time-sources.md).setTimeSource/tickandsetClock/advanceClockare one state machine over REAL / CUSTOM / VIRTUAL; the two internal truths change only together, and the doors fail closed --setTimeSource()/(null)/(undefined)restoreDate.now, anything else throwsTypeError(the old garbage-to-Date.nowcoercion is gone since 1.5.1).
A recurring theme: every reason to "tune the period" turns out to be a different primitive in disguise -- sub-second is frame timing, periodic side effects are every(), scaled time is a simulation accumulator -- which is exactly why the 1 s heartbeat is fixed and there is one of it.
Testing
Deterministic node:test suites plus a torture gate. npm test runs the numbered suites 01-core .. 11-docs (core primitives and heartbeat lifecycle; the relativeTime cutoff, unit scaling, target coercion, and cache keying; determinism and SSR safety including a child-process exit test; packaging; throw containment; the target law; cell lifecycle; auto-park; units; the time-source state machine; and the docs-drift guard), and node --expose-gc test/torture.mjs runs the retention + allocation torture board (@zakkster/lite-leak + @zakkster/lite-gc-profiler) that proves 0 leaked cells, 0 major GCs, and the per-beat allocation budget -- no gate output is a FAIL. 135 deterministic tests: 133 pass, 0 fail, 2 gc-gated skips (the two skips run under npm run test:gc; measured at the v1.5.2 gate).
npm test # behavior suites (node:test)
npm run test:gc # the same suites under --expose-gc
npm run test:watch # watch mode
npm run bench # zero-GC benchmark + node/arch/v8 fingerprint
npm run torture # @zakkster/lite-leak + lite-gc-profiler retention/alloc gate
npm run verify # test:gc + torture + bench, the publish gatetest/11-docs.test.js is a docs-drift guard: it takes Time.js's export set as the single source of truth and fails the build if Time.d.ts, llms.txt, or this README document a different surface (in either direction), if any decisions/NNNN-*.md citation dangles or any decision file goes uncited, if a relative doc link resolves to nothing, or if any shipped text/source file breaks the ASCII law -- so the three surfaces cannot silently drift from the code again.
What this is not
- Not a date library. No parsing, no arithmetic, no formatting of its own, no timezone conversion, no locale data shipped.
Intl.RelativeTimeFormatdoes the formatting;Date/Temporaldo the dates. lite-time owns reactivity over time, nothing else -- it is timezone-agnostic by construction: pass a Temporal-like{ epochMilliseconds }(or a UTC epoch-ms number) and zone semantics stay yours. - Not a sub-second clock. The heartbeat fires at 1 Hz. For per-frame cadence use
requestAnimationFrameor@zakkster/lite-raf. The 1 s period is deliberate and not configurable. - Not a simulation timeline. No
timeScale, no seek, no snapshot/hydrate of scaled game time -- that is@zakkster/lite-clock. lite-time is wall-clock only. - Not a general interval scheduler.
every()covers the common "fire every minute on the boundary" case; for arbitrary scheduling usesetTimeout/setIntervalor a job library. - Not usable without lite-signal. The reactive substrate is a peer dependency by design -- it is what makes the cutoff and auto-park work. If you only need a stand-alone ticker with no reactive graph, a 5-line
setIntervalis the right tool, not this. - Not zero-cost on the first render. The library pre-allocates cache structures and ships a small fixed per-
relativeTimeoverhead (one packed computed + one format computed + the user effect). The zero-GC claim is about steady state, not startup.
Ecosystem
The @zakkster cadence tier -- pick the primitive whose clock matches your problem:
| Package | Clock | For |
| --------------- | ------------------------ | --- |
| lite-raf | frame cadence | Zero-GC requestAnimationFrame loop; frame time as signals. |
| lite-time | wall-clock seconds | This package: one heartbeat, relative-time / countdown / interval cadence. |
| lite-clock | simulation timelines | timeScale, seek, deterministic snapshot / hydrate of scaled time. |
| lite-scheduler| frame budget | 5-lane priority scheduler for splitting work across frames. |
Part of the @zakkster zero-GC stack:
lite-signal-- zero-GC reactive graph for hot paths (the peer dependency)lite-raf-- zero-GCrequestAnimationFrameloop, frame clock as signalslite-clock-- simulation timelines:timeScale, seek, deterministic snapshot/hydratelite-scheduler-- 5-lane frame-budget priority schedulerlite-time-- this package
License
MIT (c) Zahary Shinikchiev [email protected]
