npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 -- Intl does formatting, you bring the dates.

npm version Sponsor Zero-GC npm bundle size npm downloads npm total downloads TypeScript lite-signal peer license

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-signal
import { 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-aligned

One 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

lite-time was built under four constraints simultaneously:

  1. 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.
  2. Zero allocation in steady state. A 1000-row leaderboard refreshing every second cannot allocate 1000 strings per tick. The packed-SMI cutoff means Intl.format runs only on actual display changes.
  3. Deterministic for tests and SSR. No vi.useFakeTimers() shenanigans, no flaky await sleep(). setTimeSource + tick() give you a fully scriptable virtual clock that drives the same code path as the real heartbeat.
  4. Drift-corrected and self-healing. Each beat schedules the next at period - (now % period), landing on the :00 boundary forever. No catch-up storm after a refocus. Boundary-aligned every() 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?) -- an every() that auto-pauses while the tab is hidden and catches up once on resume.
  • nowInstant() -- opt-in Temporal.Instant view of now (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 explicit startClock()).
  • 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&nbsp;computed<br/>(value &lt;&lt; 2) | unitCode"]]
    FMT[["format&nbsp;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()"| EV

One 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 SMI

Every 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 resume

Like 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 | null

Reactive 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.now

Replace 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.now

A 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 teardown

No 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 teardown

SSR / 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 time

For 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 line

The 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 missing finally once let a user callback or a throwing getter escape through the shared heartbeat flush and stop it for every subscriber. The beat is now try/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 a TypeError at the call site (no silent "55 years ago"); an invalid reactive read degrades to a display placeholder without poisoning the graph; +-Infinity are 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, idempotent dispose() (or cascade-free with their owner); the shared infrastructure -- now, the formatter cache, the memoized clock cells -- 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 / stopClock are the sticky override.
  • Full unit range by default, maxUnit opt-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/tick and setClock/advanceClock are one state machine over REAL / CUSTOM / VIRTUAL; the two internal truths change only together, and the doors fail closed -- setTimeSource()/(null)/(undefined) restore Date.now, anything else throws TypeError (the old garbage-to-Date.now coercion 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 gate

test/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.RelativeTimeFormat does the formatting; Date / Temporal do 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 requestAnimationFrame or @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 use setTimeout / setInterval or 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 setInterval is the right tool, not this.
  • Not zero-cost on the first render. The library pre-allocates cache structures and ships a small fixed per-relativeTime overhead (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-GC requestAnimationFrame loop, frame clock as signals
  • lite-clock -- simulation timelines: timeScale, seek, deterministic snapshot/hydrate
  • lite-scheduler -- 5-lane frame-budget priority scheduler
  • lite-time -- this package

License

MIT (c) Zahary Shinikchiev [email protected]