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

Readme

@zakkster/lite-throttle

Zero-GC reactive throttle for @zakkster/lite-signal. Timer-based and requestAnimationFrame-aligned variants, intent-guarded writes, synchronous emit, no per-change allocations.

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

npm install @zakkster/lite-throttle @zakkster/lite-signal
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()));

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

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 to requestAnimationFrame. Same shape, but the trailing fire happens on the next animation frame rather than after a fixed ms.

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.is equality. 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.
  • NaN is 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) from lite-signal would 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 pendingValue before out.set, so the re-entrant write isn't wiped. (This was a real bug fixed during the audit; the regression test lives in test/throttle.test.js.)
  • throttleRAF double-emit on re-entrant write. If a subscriber writes back to the source during the trailing fire, rafId has 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 bench

The 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 engage

throttle 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