@zakkster/lite-time
v1.1.0
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.
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. Sub-byte allocation per tick in steady state.
Headline: at 100 relativeTime cells with the cutoff held, lite-time runs at 16 B/tick versus 54 B/tick for the same workload hand-rolled with a fresh Intl.RelativeTimeFormat per beat -- a 3.4× allocation reduction and an ~180× wall-clock speedup (46 ms vs 8.5 s over 20,000 ticks on Node 22).
Table of contents
- Why this exists
- What you get
- Architecture in one diagram
- How a beat propagates
- The cutoff: how 100 cells cost the same as 1
- API reference
- Determinism: virtual clock for tests & SSR
every()is independent of the heartbeat- Benchmarks
- Edge cases pinned down
- What this is not
- Browser and runtime support
- Integration recipes
- Testing strategy
- FAQ
- npm scripts
Why this exists
Every UI that displays a chat list, an order feed, a leaderboard, or a "posted N minutes ago" badge needs to re-render relative-time strings on a schedule. The textbook way to do this -- setInterval(updateAll, 1000) per cell, fresh Intl.RelativeTimeFormat each tick, no cutoff -- is fine at low scale and quietly becomes a GC problem the moment your list grows. Hand-rolled engines tend to drift, fire off boundaries, and pin the Node event loop in tests.
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). - 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-started on first time-derived primitive.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.
Full type definitions ship in Time.d.ts and are referenced from package.json. Every public symbol has JSDoc.
Architecture in one diagram
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. It is 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 & 0b11 -> 0=second, 1=minute, 2=hour, 3=day
value = packed >> 2 -> sign-preserving SMIThis is what lets the cutoff work. Every beat:
- The
nowsignal force-propagates (it has to -- V8 is allowed to elideset(x)whenx === old, butequals: () => falsedefeats this). - The
packedcomputed runs and produces an SMI:(value << 2) | unitCode. - lite-signal compares the new SMI to the cached one with
Object.is. Two SMIs encoding the same(value, unit)are bitwise identical -> cutoff holds. - The outer
formatcomputed and the user effect do not re-run.
Within the same minute, every sub-second beat hits step 3 and stops. The single Intl.format allocation per cell happens only when the display actually moves -- once per minute for a "minute"-bucket cell, once per hour for an "hour"-bucket cell, once per day for a "day"-bucket cell.
A list of 100 cells displaying "N minutes ago" allocates one format string per minute, not 100 per second.
Static formatters are cached per (locale, numeric, style) triple. The Intl.RelativeTimeFormat constructor itself never runs in steady state.
API reference
import {
now, relativeTime, countdown, onElapsed,
every, nowInstant,
startClock, stopClock, tick, setTimeSource,
} 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).
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. Bind a wall-clock display that only repaints when its visible digits move.
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.
relativeTime(target, opts?)
const t = relativeTime(target, {
locale?: string | string[], // forwarded to Intl
numeric?: "auto" | "always", // default "auto"
style?: "long" | "short" | "narrow" // default "long"
});
effect(() => label.textContent = t());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).
Static invalid targets (null, undefined, NaN, an Invalid Date, non-coercibles) throw a TypeError at the call site -- no silent "55 years ago". An invalid reactive read (relativeTime(() => order?.createdAt) while order is loading) degrades to "" and self-heals once the target becomes valid; it never throws into the shared heartbeat flush.
Formatters are cached per (locale, numeric, style) triple. The cache key includes every output-affecting option, so two callers using the same options share one Intl.RelativeTimeFormat.
countdown(target)
const remain = countdown(target); // ms remaining, clamped at 0
effect(() => bar.style.width = `${(1 - remain() / total) * 100}%`);Clamped at 0 -- never goes negative. NaN from an invalid reactive read clamps to 0 (no throw). Same target coercion as relativeTime.
onElapsed(target, fn)
const stop = onElapsed(deliveryDeadline, () => showLatePopup());Fires fn once when the remaining countdown reaches zero, then self-disposes. If the target is already in the past, fires synchronously on registration. Returns a stop() to cancel before firing.
Built on lite-signal's when -- no extra dependency, no Promise allocation. Use this in hot paths.
every(ms, fn)
const stop = every(60_000, () => refreshHourlyDigest());Drift-corrected boundary-aligned interval 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).
Independent of the heartbeat: keeps working while the clock is parked, and is not driven by tick(). See every() is independent of the heartbeat.
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 zero-per-tick discipline and RangeError guard as every(). See every() is independent of the heartbeat.
nowInstant()
const inst = nowInstant(); // Temporal.Instant | nullOpt-in Temporal.Instant view of now. Returns null in runtimes without Temporal. Allocates an Instant object per beat -- use the raw now() SMI for hot paths.
startClock() / stopClock() / tick()
startClock(); // idempotent; auto-invoked by the first time-derived primitive
stopClock(); // park the heartbeat; primitives stay valid
tick(); // advance one beat by handstopClock() is for tests and graceful shutdowns. tick() is for tests and SSR -- see next section.
setTimeSource(fn?)
setTimeSource(() => fakeClock); // install
setTimeSource(); // restore Date.nowReplaces the epoch-ms source. Call with no argument or a non-function to restore Date.now. The source is the single place wall-clock time enters the library -- every read of Date.now goes through this seam.
setClock(epochMs?) / advanceClock(deltaMs)
stopClock(); // park the real timer; 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 and propagates one beat; advanceClock(deltaMs) steps it and propagates, returning the new epoch (basing itself on the current source first if no virtual clock is active). Both make now-derived primitives -- relativeTime, countdown, onElapsed, clock, nowInstant -- update synchronously with no real timers. setClock() with no / non-finite argument restores Date.now; advanceClock throws TypeError on a non-finite delta.
The independent interval timers (every / everyVisible) schedule on the real event loop and are not virtualized -- pair these with stopClock() for fully deterministic timing so the real heartbeat does not also fire. See the next section.
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.
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(); // park the auto-started timer; 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 -- it pins the source and ticks for you:
import { relativeTime, setClock, advanceClock, stopClock } from "@zakkster/lite-time";
stopClock();
setClock(Date.parse("2030-01-01T00:00:00Z")); // pin + propagate
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 auto-started 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. (The behaviour is verified by a child-process exit test in the suite.) In browsers, setTimeout returns a number with no .unref, so the guard is a no-op there. Perfectly isomorphic.
On the client, hydrate normally. The first relativeTime / countdown you create auto-starts 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 more, see TESTING-AND-SSR.md.
every() is independent of the heartbeat
every(ms, fn) runs fn on each ms wall-clock boundary, drift-corrected, on its own timer. It is not part of the 1-second heartbeat:
- Calling
every(60_000, ...)does not start the global clock, and it keeps working while the clock is parked viastopClock(). Conversely,stopClock()does not stop anevery()-- hold its returnedstop()and call it yourself. - It schedules on the real event loop, so unlike
now/relativeTime/countdownit is not driven bytick()/setTimeSource. To unit-test interval logic deterministically, test your callback directly or use your runner's fake timers; lite-time's virtual clock drives the reactive surface, notevery()'s scheduler. - Its timer is
unref'd too, so a forgottenevery()will not hang a test process. msmust be a finite number> 0; anything else (0, negatives,NaN,Infinity) throws aRangeError.everyVisible(ms, fn, opts?)is the same recipe with a battery-saver: it parks its timer while the tab is hidden and fires one catch-up on resume. Everything above still holds (own timer, real event loop,unref'd,RangeErrorguard); it just adds avisibilitychangelistener thatstop()removes. With no DOM it is exactlyevery().
Benchmarks
Honest numbers, against the same workload. All measurements: Node 22.22, --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.
Single-cell
| Scenario | heap delta (transient) | Retained | B/tick |
|---|---|---|---|
| A -- pure heartbeat: now -> effect | 248 KB | 18 KB | 1.24 |
| B -- relativeTime, display stable (cutoff holds) | 1.34 MB | 29 KB | 6.69 |
| C -- relativeTime, display changes every tick (Intl.format every beat) | 1.44 MB | -8 KB | 7.21 |
| D -- naive baseline (fresh Intl per tick, no cutoff) | 158 KB | 6 KB | 0.79 |
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 | 324 KB | 0.5 KB | 16.2 | | F -- naive × 100 cells | 1.08 MB | 0.7 KB | 54.0 |
lite-time at 100 cells allocates 3.4× less per tick than the naive equivalent, and lite-time × 100 (16.2 B/tick) is less than 2.5× the cost of lite-time × 1 (6.7 B/tick) -- the cutoff almost completely eliminates the per-cell penalty. The naive case scales linearly: 100× the cells ~ 100× the alloc.
The wall-time gap is sharper still: 20,000 ticks at 100 cells takes 46 ms with lite-time and 8.5 seconds with the naive approach -- a ~180× speedup driven mostly by suppressing 99% of the Intl.format calls.
Run it yourself:
npm run benchThe 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, no anti-DCE tricks needed (every effect's output is captured into a closure variable that V8 cannot prove dead).
Edge cases pinned down
These are the questions you'd ask in a code review, with the answers:
- Invalid static target.
relativeTime(null),relativeTime(new Date("nope")),relativeTime("abc")-- all throwTypeErrorat the call site. The "1970 trap" (silently coercingnullto0-> "55 years ago") is impossible. - Invalid reactive target.
relativeTime(() => maybeNull)whilemaybeNullisnull-- the cell renders""and the shared heartbeat keeps working for every other subscriber. The moment the target becomes valid again, the cell self-heals. Throwing here would escape through_now.set()and kill the clock for the whole graph -- by design, that cannot happen. countdownwith NaN target. Clamps to 0 -- never negative, never a throw.onElapsedwith a target already in the past. Fires synchronously on registration, then self-disposes.every(0, fn)/every(NaN, fn)/every(-1, fn). ThrowsRangeError. The library will not thrash the event loop on a degenerate input.- Auto-start then forget. The heartbeat's
setTimeoutisunref'd in Node, so a process that creates arelativeTimeand never callsstopClock()still exits when its work is done. Browsers don't haveunref, but they also don't have an event loop to pin. - Multiple
relativeTimeinstances. All share the single heartbeat. One beat -> one mark phase -> N effect re-evaluations, each gated by its own cutoff. - Formatter cache key collisions. The key is
(locale, numeric, style)joined with|. Array locales are stringified with,. Two callers with the same effective options share oneIntl.RelativeTimeFormat; differing on any one option allocates a new one. - Time source flapping mid-run.
setTimeSource(fn)is atomic -- the very nexttick()reads fromfn. No queued reads remember the old source. - Clock parked during a tick.
stopClock()only stops scheduling furthersetTimeoutcalls; in-flight ticks complete normally, andtick()itself still works (it doesn't care whether the heartbeat is running). - 32-bit issues in packed SMI. Values are clamped by the structure: seconds < 60, minutes < 60, hours < 24, days unbounded but
Math.round(s / 86400) << 2only escapes SMI range past ~5 million days. The current encoding tolerates ~13,000 years in either direction.
What this is not
- Not a date library. No parsing, no arithmetic, no timezones, no locale data shipped.
Intldoes formatting,Date/Temporaldo dates. lite-time owns reactivity over time, nothing else. - Not a sub-second clock. The heartbeat fires at 1 Hz. If you need per-frame cadence, use
requestAnimationFrameor@zakkster/lite-raf. The 1 s period is deliberate and not configurable -- every reason to tune it belongs to a different primitive. - Not a general-purpose interval library.
every()is here as a convenience for the common "fire every minute on the boundary" case. For arbitrary scheduling, usesetTimeout/setIntervaldirectly or a job library. - Not React/Vue/Svelte-specific. It's a reactive substrate. Wire it into any UI layer that can read from a
signal.subscribecallback. - Not zero-cost on the first render. The library pre-allocates the cache structures and ships a small fixed overhead per
relativeTime(one packed computed + one format computed + the user effect). The zero-GC claim is about steady state, not startup.
Browser and runtime support
Pure ES2020 + Intl.RelativeTimeFormat (Baseline 2020) + optional Temporal (gracefully null where absent). Runs anywhere modern JS runs.
| Target | Supported | | --------------------------------- | --------- | | Chrome / Edge (last 2 majors) | yes | | Firefox (last 2 majors) | yes | | Safari 14+ | yes | | Node.js 18+ | yes | | Bun | yes | | Deno | yes | | Cloudflare Workers | yes | | Twitch Extensions (1MB / 3s) | yes |
ESM-only. No CommonJS build -- modern bundlers handle this; legacy consumers can use a wrapper.
Integration recipes
Reactive chat-list "posted N minutes ago"
import { effect } from "@zakkster/lite-signal";
import { relativeTime } from "@zakkster/lite-time";
function mountTimestamp(el, message) {
const text = relativeTime(() => message.createdAt);
return effect(() => { el.textContent = text(); }); // updates on display change only
}Order-deadline countdown + late-popup
import { effect } from "@zakkster/lite-signal";
import { countdown, onElapsed } from "@zakkster/lite-time";
function bindDeadline(barEl, order) {
const total = order.totalDuration;
const remain = countdown(order.deadline);
const off1 = effect(() => {
barEl.style.width = `${(1 - remain() / total) * 100}%`;
});
const off2 = onElapsed(order.deadline, () => showLatePopup(order));
return () => { off1(); off2(); };
}Hourly digest refresh, drift-corrected
import { every } from "@zakkster/lite-time";
const stop = every(60 * 60_000, async () => {
const digest = await fetch("/api/digest").then((r) => r.json());
paintDigest(digest);
});
// stop() to cancel; safe to leave running -- the timer is unref'd in Node.Twitch Extension overlay with a server-time master clock
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 timeDeterministic test (node --test)
import { effect } from "@zakkster/lite-signal";
import { relativeTime, setTimeSource, tick, stopClock } from "@zakkster/lite-time";
test("badge updates as time passes", () => {
let clock = 1_700_000_000_000;
setTimeSource(() => clock);
stopClock();
const t = relativeTime(() => clock - 60_000);
let text; const off = effect(() => { text = t(); });
assert.equal(text, "1 minute ago");
clock += 60_000; tick();
assert.equal(text, "2 minutes ago");
off(); setTimeSource();
});Testing strategy
Three tiers, all reproducible.
Tier 1 -- Behavior (unit tests)
npm test runs the suite in test/:
01-core.test.js-- clock primitives (now,peek,subscribe), heartbeat lifecycle (startClock/stopClock/tick),setTimeSourceinstall/restore, idempotency contracts.02-formatters.test.js--relativeTimecutoff (re-renders only on display change), unit scaling (seconds/minutes/hours/days),Intlopts (locale/numeric/style), target coercion (number/Date/Temporal/getter), static-invalid throws, reactive-invalid degrades and self-heals,countdownclamping,onElapsedone-shot, single-heartbeat shared across many instances.03-determinism.test.js--__testseam (SMI pack round-trip, boundary alignment,Intlcache keying),every()independence (own timer, not driven bytick(), survivesstopClock()),nowInstantTemporal interop, SSR safety (child-process exits cleanly).
npm test30 tests, ~430 ms wall clock.
Tier 2 -- Memory (zero-GC benchmark)
npm run bench runs the four-scenario allocation benchmark from the Benchmarks section with --expose-gc. Reports transient and retained heap separately. Re-runs are stable to within ~10%; the relative ordering (B vs C, E vs F) is what matters.
npm run benchTier 3 -- Full verify (pre-publish gate)
npm run verify # test + benchFAQ
Why one heartbeat for everything?
Because the period (1 s) is not a caller-tunable knob -- it's a wall-clock semantic. Sub-second cadence is a different primitive (frame timing). Game time is a different primitive (scaled accumulator). Periodic side effects are a different primitive (every). Cells that all need "this many minutes ago" share the same beat by definition, so they share the same timer.
Why a packed-SMI cutoff instead of comparing strings?
Three reasons: (1) string comparison allocates the string in the first place -- the cutoff would be after the cost; (2) Object.is on an SMI is bitwise; (3) the SMI encodes (value, unit) together, so the cutoff is exact (a beat that crosses from "59 seconds ago" to "1 minute ago" both decreases the value and changes the unit -- both must mismatch for the cutoff to release, and they will).
Why is the heartbeat period not configurable? Already answered above, but the short version: every reason to "tune the period" turns out to be a different primitive in disguise. A configurable period would let you build the wrong thing more easily.
Why no microtask scheduling like in lite-signal?
lite-signal answers signal.set() synchronously, so when a tick 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.
Why does setTimeSource(garbage) not throw?
Because in practice setTimeSource() and setTimeSource(null) and setTimeSource(undefined) all mean "restore the default", and threading "is this argument a function?" through user-land try/catch is annoying. Non-function arguments are reinterpreted as "restore Date.now". Documented behaviour, never silent corruption.
My SSR process is hanging -- what gives?
Almost certainly not lite-time. The heartbeat's setTimeout is unref'd, so Node's event loop ignores it for exit purposes. If you have an unref'd timer pinning the loop, it's not from this library. Check your database driver, your tracing exporter, your test runner. The SSR-safety test in this repo's suite proves the import-and-use path exits cleanly within 4 seconds.
Can I use this without lite-signal?
No -- the reactive substrate is a peer dependency by design. If you need a stand-alone "ticker" primitive without a reactive graph, write a 5-line setInterval instead.
What about timezones?
lite-time doesn't have an opinion. Pass a Temporal.ZonedDateTime if you want zone-aware semantics, or a UTC epoch-ms if you don't. The library is timezone-agnostic by construction.
Will I see drift if my tab goes to background?
The boundary-aligned setTimeout will accumulate 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.
npm scripts
npm test # behavior suite, 30 tests, ~430 ms
npm run bench # zero-GC benchmark, ~30 s with --expose-gc
npm run verify # all of the above; gate for publishLicense
MIT (c) Zahary Shinikchiev
Part of the @zakkster zero-GC stack:
lite-signal-lite-raf-lite-ecs-lite-ease-lite-pointer-tracker-lite-bmfont-lite-color
