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

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

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

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:

  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).
  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-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&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. 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 SMI

This is what lets the cutoff work. Every beat:

  1. The now signal force-propagates (it has to -- V8 is allowed to elide set(x) when x === old, but equals: () => false defeats this).
  2. The packed computed runs and produces an SMI: (value << 2) | 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 -> 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. 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 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 zero-per-tick discipline and RangeError guard as every(). See every() is independent of the heartbeat.

nowInstant()

const inst = nowInstant();                // Temporal.Instant | null

Opt-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 hand

stopClock() is for tests and graceful shutdowns. tick() is for tests and SSR -- see next section.

setTimeSource(fn?)

setTimeSource(() => fakeClock);           // install
setTimeSource();                          // restore Date.now

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

A 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 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 -- 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 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 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 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; lite-time's virtual clock drives the reactive surface, not every()'s scheduler.
  • Its timer is unref'd too, so a forgotten every() will not hang a test process.
  • ms must be a finite number > 0; anything else (0, negatives, NaN, Infinity) throws a RangeError.
  • 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, RangeError guard); it just adds a visibilitychange listener that stop() removes. With no DOM it is exactly every().

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 bench

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, 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 throw TypeError at the call site. The "1970 trap" (silently coercing null to 0 -> "55 years ago") is impossible.
  • Invalid reactive target. relativeTime(() => maybeNull) while maybeNull is null -- 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.
  • countdown with NaN target. Clamps to 0 -- never negative, never a throw.
  • onElapsed with a target already in the past. Fires synchronously on registration, then self-disposes.
  • every(0, fn) / every(NaN, fn) / every(-1, fn). Throws RangeError. The library will not thrash the event loop on a degenerate input.
  • Auto-start then forget. The heartbeat's setTimeout is unref'd in Node, so a process that creates a relativeTime and never calls stopClock() still exits when its work is done. Browsers don't have unref, but they also don't have an event loop to pin.
  • Multiple relativeTime instances. 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 one Intl.RelativeTimeFormat; differing on any one option allocates a new one.
  • Time source flapping mid-run. setTimeSource(fn) is atomic -- the very next tick() reads from fn. No queued reads remember the old source.
  • Clock parked during a tick. stopClock() only stops scheduling further setTimeout calls; in-flight ticks complete normally, and tick() 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) << 2 only 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. Intl does formatting, Date / Temporal do 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 requestAnimationFrame or @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, use setTimeout / setInterval directly 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.subscribe callback.
  • 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 time

Deterministic 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), setTimeSource install/restore, idempotency contracts.
  • 02-formatters.test.js -- relativeTime cutoff (re-renders only on display change), unit scaling (seconds/minutes/hours/days), Intl opts (locale/numeric/style), target coercion (number/Date/Temporal/getter), static-invalid throws, reactive-invalid degrades and self-heals, countdown clamping, onElapsed one-shot, single-heartbeat shared across many instances.
  • 03-determinism.test.js -- __test seam (SMI pack round-trip, boundary alignment, Intl cache keying), every() independence (own timer, not driven by tick(), survives stopClock()), nowInstant Temporal interop, SSR safety (child-process exits cleanly).
npm test

30 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 bench

Tier 3 -- Full verify (pre-publish gate)

npm run verify   # test + bench

FAQ

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 publish

License

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