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

v1.0.2

Published

Zero-GC reactive debounce on @zakkster/lite-signal. Trailing and leading-edge variants. Intent-guarded, synchronous emit, no per-change allocations.

Readme

@zakkster/lite-debounce

Zero-GC reactive debounce for @zakkster/lite-signal. Trailing and leading-edge 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-debounce @zakkster/lite-signal
import { signal, effect } from "@zakkster/lite-signal";
import { debounce } from "@zakkster/lite-debounce";

const query = signal("");
const debouncedQuery = debounce(() => query(), 300);

effect(() => runSearch(debouncedQuery()));

query.set("a"); query.set("ab"); query.set("abc");
// 300 ms later: runSearch("abc") — one call.

Synchronous emit. No per-change closure. The trailing fire reuses one pre-allocated flush closure for the life of the instance.


⚠️ Imperative vs. Reactive (Coming from Lodash?)

lite-debounce is not a drop‑in replacement for lodash.debounce.

Lodash is imperative:
You wrap a callback and push values into it manually.

lite-debounce 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 { debounce } from "@zakkster/lite-debounce";

// This will NOT work. It tracks no reactive dependencies.
const logIt = debounce((v) => console.log(v), 100);

document.addEventListener("mousemove", (e) => logIt(e.clientX));

✅ GOOD: Reactive pull (lite-signal style)

import { signal, effect } from "@zakkster/lite-signal";
import { debounce } from "@zakkster/lite-debounce";

// 1. Define the reactive source
const mouseX = signal(0);

// 2. Derive the debounced state
const debouncedX = debounce(() => mouseX(), 100);

// 3. Update the source imperatively
document.addEventListener("mousemove", (e) => mouseX.set(e.clientX));

// 4. React to the debounced state automatically
effect(() => console.log("Debounced X:", debouncedX()));

Contents


Why this exists

A naive wrapper around setTimeout allocates a fresh closure on every source change AND calls clearTimeout(prev) + setTimeout(new) per write:

// allocates one closure per source.set, plus clearTimeout+setTimeout churn
effect(() => {
    const v = source();
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => sink(v), ms);   // <-- this arrow, every time
});

At 60 fps on a fast-moving source that's 60 short-lived closures per second per debounced value, plus 120 timer-queue mutations. Stack that across a HUD and the minor GC pauses are real.

lite-debounce hoists the timer callback (flush) to construction time and reuses it forever. It also uses a sliding-timestamp scheme: instead of clearing and re-arming the timer on every source change, it records lastWriteTime and lets the existing timer fire. When it fires, flush checks whether ms of quiet has actually elapsed; if not, it re-arms itself for the remaining gap. Amortized cost: one setTimeout per quiet window, regardless of burst size — O(1) timer-queue allocations per burst instead of O(N).

Per source change, this file allocates zero JS-heap objects.


What you get

  • debounce(sourceFn, ms?) — trailing-edge debounce. The most recent value emits ms after the last source change. ms === 0 uses queueMicrotask instead of setTimeout — coalescing without timer-queue churn.
  • debounceLeading(sourceFn, ms?, { trailing? }) — leading-edge debounce. The first change emits immediately; further changes are locked out for ms. If trailing: true, the most recent in-lockout value emits at expiry.

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

debounce(sourceFn, ms = 0)

function debounce<T>(sourceFn: () => T, ms?: number): ReadonlyDerived<T>;

Trailing-edge debounce. sourceFn is read inside an effect; the returned api re-emits a debounced view of that read.

  • ms > 0setTimeout-based trailing fire. Each in-window source change resets the timer.
  • ms === 0queueMicrotask-based coalescing. Every change inside one synchronous task collapses into one fire on the next microtask.

debounceLeading(sourceFn, ms = 0, { trailing = false } = {})

function debounceLeading<T>(
    sourceFn: () => T,
    ms?: number,
    options?: { trailing?: boolean }
): ReadonlyDerived<T>;

Leading-edge debounce. Emits the first non-equal change immediately. Further changes inside the ms lockout are dropped, or (with trailing: true) the latest is queued and emitted at expiry.

ms === 0 collapses the lockout window to zero — every change emits a leading edge. Documented, not a bug; useful as a no-op equality-guarded pass-through.


Examples

Search input (classic trailing):

import { signal, effect } from "@zakkster/lite-signal";
import { debounce } from "@zakkster/lite-debounce";

const query = signal("");
const debounced = debounce(() => query(), 300);
effect(() => searchApi(debounced()));

Leading button-click guard (no double-fire):

import { signal } from "@zakkster/lite-signal";
import { debounceLeading } from "@zakkster/lite-debounce";

const click = signal(0);
const guarded = debounceLeading(() => click(), 500);  // trailing: false
guarded.subscribe(() => submitOrder());

// Rapid double-click: only the first fires submitOrder.
click.update(n => n + 1);
click.update(n => n + 1);

Microtask coalescing in a batched update:

import { signal, batch } from "@zakkster/lite-signal";
import { debounce } from "@zakkster/lite-debounce";

const x = signal(0);
const view = debounce(() => x());        // ms = 0
view.subscribe(v => render(v));

batch(() => {
    for (let i = 0; i < 1000; i++) x.set(i);
});
// One render(999), one microtask later.

When to use which

| You want | Use | | ------------------------------------------------------- | -------------------------------------------- | | Fire after the burst settles | debounce(src, ms) | | Fire immediately, ignore the rest of the burst | debounceLeading(src, ms) | | Fire immediately AND once more at end | debounceLeading(src, ms, { trailing: true }) | | Coalesce a synchronous burst into one microtask | debounce(src) (ms = 0) | | Cap emit rate during the burst, not just after | throttle / throttleRAF (separate package) |

debounce waits for quiet. throttle (in @zakkster/lite-throttle) emits at a fixed rate during the burst. They solve different problems — don't reach for one when you mean the other.


Semantics worth knowing

These aren't gotchas — they're the consequences of being signal-native:

  • Equality-passthrough. The output signal uses lite-signal's default Object.is equality. If a trailing or leading emit equals the current output, subscribers don't notify. This matches the rest of the lite-signal contract (emit on change, not on write). If you need notify-on-every-fire regardless of value, project the source through a tuple or attach a write-token.
  • NaN is its own match. Object.is(NaN, NaN) === true, so a NaN-to-NaN source change is short-circuited by the intent guard.
  • Disposal is on the api, not via the polymorphic helper. The api is a callable function (reading the value). dispose(api) from lite-signal would invoke it as an effect handle (read the value). Always call api.dispose() directly.
  • Re-entrant writes are safe. A subscriber that writes back to the source during the trailing fire correctly queues a new flush — 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/debounce.test.js.)
  • ms === 0 microtask path. In a synchronous burst, all changes coalesce to one fire one microtask later. Inside a batch(), the coalesced fire happens after the batch closes (since the effect is queued, not invoked inline).

Allocation profile

| Op | JS-heap allocations from this file | Notes | | -------------------------- | ----------------------------------- | ----- | | debounce() construction | 1 signal node, 1 effect, links + 4 closures | One-time, from the pool. | | Per source change (steady) | 0 | Hot-path budget honored. | | Per trailing fire | 0 | flush is pre-allocated. | | setTimeout(flush, ms) | V8-internal Timeout object | Unavoidable for trailing semantics; same cost in lodash. | | queueMicrotask(flush) | V8-internal microtask record | Cheaper than setTimeout; no JS-heap object. |

The "naive" wrapper at the top of this file allocates a fresh closure on every setTimeout arm. lite-debounce doesn't.


Benchmarks

Run yourself:

npm install --no-save lodash.debounce
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 along with [min..max] so V8 JIT tier-up variance is visible instead of looking like a bench bug. Two forced GCs before and after each round; Δheap/op is the surviving allocation.

Reference numbers on a Linux x64 sandbox (Node 22), trailing debounce, ms=10:

| Implementation | median ops/s | min..max | Δheap/op | | --------------------------------------- | ------------ | ---------------- | -------- | | @zakkster/lite-debounce.debounce | 5,391K | 4,306K..5,899K | 0.08 B | | naive effect + setTimeout closure | 2,802K | 2,711K..2,990K | 0.21 B | | lodash.debounce in effect | 4,329K | 3,626K..4,845K | 0.21 B |

Leading + trailing, ms=10:

| Implementation | median ops/s | min..max | Δheap/op | | ------------------------------------------- | ------------ | ---------------- | -------- | | @zakkster/lite-debounce.debounceLeading | 7,349K | 6,977K..8,501K | 0.15 B | | lodash.debounce leading+trailing | 4,259K | 2,762K..4,801K | 0.22 B |

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

Two tiers:

npm test          # behavior suite, fast (vitest with fake timers)
npm run test:gc   # forks worker with --expose-gc; heap-delta tests engage

The GC-required tests skip silently when globalThis.gc is absent, so npm test is clean without --expose-gc. npm run test:gc runs them. Suites cover trailing emit, leading emit, intent guard, NaN short-circuit, dispose mid-flight, dispose with pending fire, the re-entrant snapshot regression, the ms === 0 microtask path, and steady-state heap delta.


License

MIT © Zahary Shinikchiev


Part of the @zakkster zero-GC stack: lite-signal · lite-throttle · lite-ecs · lite-ease · lite-pointer-tracker · lite-bmfont · lite-color