@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.
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?)— trailing-edge debounce. The most recent value emitsmsafter the last source change.ms === 0usesqueueMicrotaskinstead ofsetTimeout— coalescing without timer-queue churn.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
}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 > 0—setTimeout-based trailing fire. Each in-window source change resets the timer.ms === 0—queueMicrotask-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.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/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 engageThe 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
