@zakkster/lite-throttle
v1.0.2
Published
Zero-GC reactive throttle on @zakkster/lite-signal. Timer-based and rAF-aligned variants. Intent-guarded, synchronous emit, no per-change allocations.
Maintainers
Readme
@zakkster/lite-throttle
Zero-GC reactive throttle for
@zakkster/lite-signal. Timer-based andrequestAnimationFrame-aligned variants, intent-guarded writes, synchronous emit, no per-change allocations.
npm install @zakkster/lite-throttle @zakkster/lite-signalimport { signal, effect } from "@zakkster/lite-signal";
import { throttleRAF } from "@zakkster/lite-throttle";
const pointer = signal({ x: 0, y: 0 });
const framed = throttleRAF(() => pointer());
effect(() => drawCursor(framed()));
// Fast input source — the canvas only redraws once per frame.
canvas.addEventListener("pointermove", (e) => {
pointer.set({ x: e.clientX, y: e.clientY });
});Synchronous leading emit, frame-aligned trailing fire. The hot path is straight integer arithmetic on three closures pre-allocated at construction.
⚠️ Imperative vs. Reactive (Coming from Lodash?)
lite-throttle is not a drop‑in replacement for lodash.throttle.
Lodash is imperative:
You wrap a callback and push values into it manually.
lite-throttle is reactive:
You wrap a tracked state source, and the reactive graph pulls updates automatically.
If you try to use it like Lodash, it will run exactly once, register zero dependencies, and never fire again.
❌ BAD: Imperative push (Lodash style)
import { throttle } from "@zakkster/lite-throttle";
// This will NOT work. It tracks no reactive dependencies.
const draw = throttle((v) => render(v), 16);
document.addEventListener("mousemove", (e) => draw(e.clientX));✅ GOOD: Reactive pull (lite-signal style)
import { signal, effect } from "@zakkster/lite-signal";
import { throttle } from "@zakkster/lite-throttle";
// 1. Define the reactive source
const mouseX = signal(0);
// 2. Derive the throttled state
const throttledX = throttle(() => mouseX(), 16);
// 3. Update the source imperatively
document.addEventListener("mousemove", (e) => mouseX.set(e.clientX));
// 4. React to the throttled state automatically
effect(() => render(throttledX()));Contents
- Why this exists
- What you get
- API reference
- Examples
- When to use which
- Semantics worth knowing
- Allocation profile
- Benchmarks
- Testing
- License
Why this exists
A pointer or scroll source can fire 1000+ events per second. Naive: render on every event, drop frames. Better: throttle. Naive throttle: read the clock on every event, allocate a fresh setTimeout closure per emission. Better still: skip the clock during the lockout (since the value isn't consulted there) and hoist the timer callback to construction time.
lite-throttle does both. The flush body is pre-allocated; the effect body is pre-allocated; performance.now() is only called when the lockout has potentially expired (timerId === null for throttle, rafId === 0 for throttleRAF). During an active burst — the realistic hot path — every write goes through three lines of state mutation: intent guard, queue pending, return. No clock read, no timer arm.
Per source change in the hot lockout path, this file allocates zero JS-heap objects.
throttleRAF is the variant for render-driven sources — its emissions align with the host's frame loop, which is what you actually want for cursor trails, scroll-driven parallax, and HUD updates pinned to vsync.
What you get
throttle(sourceFn, ms)— leading + trailing throttle. Emits the first change immediately; subsequent in-window changes coalesce into one trailing fire at lockout expiry.throttleRAF(sourceFn)— leading + trailing throttle aligned torequestAnimationFrame. Same shape, but the trailing fire happens on the next animation frame rather than after a fixedms.
Both return a read-only callable api:
interface ReadonlyDerived<T> {
(): T; // tracked read
peek(): T; // untracked read
subscribe(fn: (v: T) => void): () => void; // returns unsubscribe
dispose(): void; // cancel + release
}API reference
throttle(sourceFn, ms)
function throttle<T>(sourceFn: () => T, ms: number): ReadonlyDerived<T>;Timer-based leading + trailing throttle. ms is required — there is no useful zero-window throttle.
- Leading edge fires immediately on the first non-equal change.
- Subsequent in-lockout changes queue. The most recent value emits at lockout expiry (trailing edge).
- A leading-only quiet window leaves no trailing fire and no armed timer.
throttleRAF(sourceFn)
function throttleRAF<T>(sourceFn: () => T): ReadonlyDerived<T>;Frame-aligned leading + trailing throttle. The lockout window is "one animation frame". Requires a host requestAnimationFrame / cancelAnimationFrame — browser, modern Node, Deno.
For tests, stub globalThis.requestAnimationFrame with a manual queue (see test/throttleRAF.test.js).
Examples
Cursor render at the frame rate, regardless of pointer event frequency:
import { signal, effect } from "@zakkster/lite-signal";
import { throttleRAF } from "@zakkster/lite-throttle";
const pointer = signal({ x: 0, y: 0 });
const framed = throttleRAF(() => pointer());
effect(() => drawCursor(framed()));
canvas.addEventListener("pointermove", (e) => {
pointer.set({ x: e.clientX, y: e.clientY });
});Resize handler capped at 16 ms (≈ 60 Hz):
import { signal } from "@zakkster/lite-signal";
import { throttle } from "@zakkster/lite-throttle";
const viewport = signal({ w: window.innerWidth, h: window.innerHeight });
const throttled = throttle(() => viewport(), 16);
throttled.subscribe(({ w, h }) => relayout(w, h));
window.addEventListener("resize", () => {
viewport.set({ w: window.innerWidth, h: window.innerHeight });
});Scroll-driven HUD update once per frame:
import { signal, effect } from "@zakkster/lite-signal";
import { throttleRAF } from "@zakkster/lite-throttle";
const scrollY = signal(0);
const yPerFrame = throttleRAF(() => scrollY());
effect(() => hud.setProgress(yPerFrame() / document.body.scrollHeight));
window.addEventListener("scroll", () => scrollY.set(window.scrollY), { passive: true });When to use which
| You want | Use |
| ------------------------------------------------------- | ------------------------------ |
| Emit at a fixed-ms cadence regardless of frame timing | throttle(src, ms) |
| Emit aligned to the render frame | throttleRAF(src) |
| Wait for the burst to settle, then fire once | debounce (separate package) |
| Fire only the first event in a window | debounceLeading (separate) |
throttle and throttleRAF both emit during a burst. debounce emits after a burst. If your dropped output is mid-burst noise, debounce. If you want a refresh rate during the burst, throttle.
Use throttleRAF when the consumer renders (canvas, WebGL, transform-style DOM mutation). Use throttle(src, 16) when the consumer does anything other than render at vsync — requestAnimationFrame is paused in background tabs, and you usually don't want your data updates to pause with it.
Semantics worth knowing
- Equality-passthrough. The output signal uses lite-signal's default
Object.isequality. A leading or trailing emit whose value matches the current output won't notify subscribers. If you need notify-on-every-fire regardless of value, project the source through a tuple. NaNis its own match. Same as everywhere in lite-signal —Object.is(NaN, NaN) === true.- Disposal is on the api, not via the polymorphic helper. The api is a callable. Always call
api.dispose()directly;dispose(api)fromlite-signalwould invoke it as an effect handle. - Re-entrant writes are safe. A subscriber that writes back to the source during the trailing fire correctly queues a new emission — the implementation snapshots and clears
pendingValuebeforeout.set, so the re-entrant write isn't wiped. (This was a real bug fixed during the audit; the regression test lives intest/throttle.test.js.) throttleRAFdouble-emit on re-entrant write. If a subscriber writes back to the source during the trailing fire,rafIdhas just been cleared and the re-queued effect run takes the leading-edge branch — opening a new rAF window with that value. The consumer sees two emissions in one tick: the trailing, then the new leading. Correct, but worth knowing if you write feedback loops.
Allocation profile
| Op | JS-heap allocations from this file | Notes |
| ----------------------------- | ----------------------------------- | ----- |
| throttle() / throttleRAF() construction | 1 signal node, 1 effect, links + 3 closures | One-time, from the pool. |
| Per source change inside lockout | 0 | Three-line state mutation. |
| Per leading edge | 0 (one setTimeout or rAF arm) | V8-internal timer / rAF record. |
| Per trailing fire | 0 | flush is pre-allocated. |
throttleRAF's steady-state hot path during a burst is the tightest of the four utilities in lite-debounce + lite-throttle — no timer queue churn at all once the first rAF is armed.
Benchmarks
Run yourself:
npm install --no-save lodash.throttle
npm run benchThe harness runs 5 rounds of 100,000 source writes per implementation, after a 10,000-iteration warm-up, and reports the median with [min..max] so V8 JIT tier-up variance is visible instead of looking like a bench bug.
Reference numbers on a Linux x64 sandbox (Node 22), timer-based throttle, ms=10:
| Implementation | median ops/s | min..max | Δheap/op |
| --------------------------------------- | ------------ | ---------------- | -------- |
| @zakkster/lite-throttle.throttle | 9,169K | 5,879K..9,748K | 0.08 B |
| naive effect + setTimeout closure | 4,981K | 4,776K..5,949K | 0.21 B |
| lodash.throttle in effect | 4,113K | 3,345K..4,732K | 0.21 B |
rAF-aligned throttle (stubbed rAF, in-lockout writes):
| Implementation | median ops/s | min..max | Δheap/op |
| --------------------------------------- | ------------ | ---------------- | -------- |
| @zakkster/lite-throttle.throttleRAF | 7,269K | 6,982K..9,233K | 0.14 B |
| naive effect + rAF closure | 6,719K | 6,624K..7,132K | 0.22 B |
The throttleRAF bench uses a stubbed requestAnimationFrame whose queue is never drained during the timed loop, so every measured write hits the lockout branch — the realistic hot path during a burst.
Your numbers will differ — JIT behavior is hardware- and Node-version-specific. The ordering between implementations is what's portable. Re-run on the publish target and paste the table.
Testing
npm test # behavior suite, fast (vitest with fake timers, fake rAF)
npm run test:gc # forks worker with --expose-gc; heap-delta tests engagethrottle tests use vi.useFakeTimers() (which mocks performance.now in vitest ≥ 1.0; a sanity check at the top of the file guards against older versions).
throttleRAF tests use a manual requestAnimationFrame stub installed in beforeEach and restored in afterEach — see test/throttleRAF.test.js. The stub uses snapshot-and-drain so a callback re-arming rAF doesn't fire in the same tickRAF().
Suites cover leading emit, trailing emit, intent guard, NaN short-circuit, dispose mid-lockout, the re-entrant snapshot regression (timer variant), the re-entrant double-emit semantics (rAF variant), and steady-state heap delta.
License
MIT © Zahary Shinikchiev
Part of the @zakkster zero-GC stack:
lite-signal·lite-debounce·lite-ecs·lite-ease·lite-pointer-tracker·lite-bmfont·lite-color
