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

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 sponsor 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?, { maxWait? }) -- 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. maxWait (1.1) caps the total delay so a sustained burst still emits at least every maxWait ms.
  • 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
    cancel(): void;                                // 1.1: drop the pending emission
    flush(): T;                                    // 1.1: emit the pending value now
}

API reference

debounce(sourceFn, ms = 0, { maxWait = 0 } = {})

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

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

  • ms > 0 -- setTimeout-based trailing fire, sliding-timestamp (one timer per quiet window, not per change).
  • ms === 0 -- queueMicrotask-based coalescing. Every change inside one synchronous task collapses into one fire on the next microtask.
  • maxWait (1.1) -- maximum total delay in ms. While changes keep arriving, the value is still forced out at least once every maxWait ms (measured from the first change of the pending window). 0 (default) disables the cap; only applies when ms > 0; a non-finite or non-positive value throws RangeError. Use it for an input box that should refresh periodically during continuous typing.

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.

.cancel() and .flush() (1.1)

Both debounce and debounceLeading apis expose two control methods:

const d = debounce(() => query(), 300);
d.cancel();            // drop any pending emission; the output keeps its current value
const v = d.flush();   // emit the pending value synchronously now (no-op if nothing pending); returns the current output

cancel() clears the pending timer / microtask and discards the buffered value -- the instance stays live and resumes debouncing on the next source change. flush() bypasses the remaining wait and emits immediately. These are the reactive analogues of lodash's methods: there is no callback to invoke, so they act on the buffered value and the output signal.


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 delta/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 delta/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 delta/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, on node --test (no test-runner dependency):

npm test          # behavior suite, fast
npm run test:gc   # adds --expose-gc; heap-delta tests engage

The GC-required tests skip silently when globalThis.gc is absent, so npm test runs cleanly without --expose-gc. Timer-dependent tests drive a lockstep fake performance.now advanced in 1ms steps alongside node:test mock timers; the lodash.debounce devDependency is kept for parity comparisons.

| File | Tests | Coverage | |---|---:|---| | test/01-core.test.js | 12 | trailing coalescing, sliding timer, ms === 0 microtask, maxWait mid-burst force, cancel/flush, debounceLeading leading+trailing+flush | | test/02-leading-corner-cases.test.js | 10 | leading-engine intent guard, NaN dedupe, dispose mid-lockout, re-entrant write on the leading edge, ms === 0 documented pass-through, fresh leading after lockout, trailing:false permanent-drop | | test/03-trailing-corner-cases.test.js | 10 | trailing-engine intent guard (output-equal, pending-equal, NaN-NaN), output equality passthrough via lite-signal Object.is, dispose mid-burst, dispose idempotency, multi-subscriber, no-spurious-emit on idle | | test/04-degenerate-and-api.test.js | 8 | default ms, empty options, api-shape contract on both engines (callable + peek + subscribe + dispose + cancel + flush), dispose stops future emissions | | test/zero-gc.test.js | 5 | hot-path retention budget < 10 B/op (min of 3 runs, both engines) + structural twin coalesce tests (10k writes → 1 trailing; 10k → leading + 1 trailing; 10k → leading only with trailing:false) | | total | 45 | |


License

MIT (c) Zahary Shinikchiev


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