@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.
Maintainers
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 install @zakkster/lite-debounce @zakkster/lite-signalimport { 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
- What you get
- API reference
- Examples
- When to use which
- Semantics worth knowing
- Allocation profile
- Benchmarks
- Testing
- License
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 emitsmsafter the last source change.ms === 0usesqueueMicrotaskinstead ofsetTimeout-- coalescing without timer-queue churn.maxWait(1.1) caps the total delay so a sustained burst still emits at least everymaxWaitms.debounceLeading(sourceFn, ms?, { trailing? })-- leading-edge debounce. The first change emits immediately; further changes are locked out forms. Iftrailing: 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 everymaxWaitms (measured from the first change of the pending window).0(default) disables the cap; only applies whenms > 0; a non-finite or non-positive value throwsRangeError. 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 outputcancel() 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.isequality. 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. NaNis its own match.Object.is(NaN, NaN) === true, so aNaN-to-NaNsource 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)fromlite-signalwould invoke it as an effect handle (read the value). Always callapi.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
pendingValuebeforeout.set, so the re-entrant write isn't wiped. (This was a real bug fixed during the audit; the regression test lives intest/debounce.test.js.) ms === 0microtask path. In a synchronous burst, all changes coalesce to one fire one microtask later. Inside abatch(), 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 benchThe 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 engageThe 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
