5 min read
I bundled the two common debounce helpers on this box: perfect-debounce 2.1.0 came out at 447 bytes gzip, while lodash.debounce 4.0.8 came out at 1,163 bytes. Both fired once after a trailing burst. The bigger differences were API shape, not the microsecond invoke cost.
Short answer: pick perfect-debounce when you want a tiny, promise-returning debounce for async work. Keep lodash.debounce when you need maxWait or the classic leading-plus-trailing double fire. No affiliate links in this post.
| Build (esbuild browser minify) | Minified | gzip -9 |
|---|---|---|
| perfect-debounce | 715 B | 447 B |
| lodash.debounce | 2,351 B | 1,163 B |
About 2.6× smaller gzip. That is the number I would put on a PR when someone imports debounce into a client bundle. Track it with size-limit vs bundlesize. If debounce is the last lodash holdout, the same cleanup pattern shows up in es-toolkit vs lodash and defu vs lodash.merge.
| Case | perfect-debounce 2.1.0 | lodash.debounce 4.0.8 |
|---|---|---|
| 5 rapid calls, trailing default | 1 fire | 1 fire |
| leading true, trailing false | 1 fire (immediate) | 1 fire (immediate) |
| leading true, trailing true | 1 fire (immediate) | 2 fires (immediate + trailing) |
| cancel before wait ends | 0 fires | 0 fires |
| flush while pending | 1 fire | 1 fire |
| maxWait | not supported | supported (fired during a 200 ms spam) |
| Return value | Promise | undefined (sync wrapper) |
| isPending | yes | no (has pending() in full lodash, not this package) |
Trailing defaults match. The leading-plus-trailing case does not. perfect-debounce is built around promises: a burst of calls resolves to the last invocation's result (I saw three distinct Promise objects all resolve to 6 after f(1), f(2), f(3) with a ×2 async body). lodash.debounce returns undefined from the wrapper. For UI state libraries that already lean tiny, compare the same size bias in jotai vs zustand and nanostores vs jotai.
| Rapid calls (50,000, median of 9, 2 runs) | perfect-debounce | lodash.debounce |
|---|---|---|
| wait 0, µs per call | 0.303 / 0.272 | 0.066 / 0.103 |
| wait 16, µs per call | 0.276 / 0.227 | 0.069 / 0.113 |
| create 20,000 wrappers, µs each | 0.080 / 0.101 | 0.082 / 0.125 |
lodash wins the micro-benchmark. For a 200 ms search box debounce, wait time dominates by orders of magnitude, so I would not choose on invoke cost. Query-key and fetch coalescing are different tools; see TanStack Query vs SWR if that is the real problem.
| Situation | My pick |
|---|---|
| Async handler, want a Promise | perfect-debounce |
| Client bundle, size matters | perfect-debounce |
| Need maxWait so a call cannot starve | lodash.debounce |
| Need leading and trailing both to fire | lodash.debounce |
| Already on lodash.debounce with tests for double-fire | stay |
Bottom line: perfect-debounce was 447 B gzip versus 1,163 B for lodash.debounce, with matching trailing-once behavior and a cleaner async API on this box. Who should not switch: code that relies on maxWait or on leading-plus-trailing firing twice. Copy helpers next to debounce often come from the same lodash migration; klona vs structuredClone covers the clone side.
How this was made: I installed both packages on the ShopperCove box, measured esbuild browser bundles, timed create and invoke loops in two order-reversed runs, and checked trailing, leading, cancel, flush, maxWait and promise behavior. The JSON results sit next to the scripts. The write-up was drafted with AI help and checked against that output.
Lab evidence
Hands-on on ShopperCove box 6 Oct 2026 ~01:40 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 22.20.0). perfect-debounce 2.1.0 vs lodash.debounce 4.0.8, esbuild 0.28.2 browser ESM minify + gzip -9. Size: perfect-debounce 715/447 B vs lodash.debounce 2,351/1,163 B. Speed (9-round median, order-reversed): create 20,000 wrappers pd 0.080 / 0.101 µs vs lodash 0.082 / 0.125 µs; invoke 50,000 wait0 pd 0.303 / 0.272 µs vs lodash 0.066 / 0.103; wait16 pd 0.276 / 0.227 vs lodash 0.069 / 0.113. Behavior: trailing-once 1/1; L+T lodash 2 vs pd 1; cancel 0/0; flush 1/1; maxWait lodash fires during 200 ms spam; pd promise burst f(1..3) resolves all to 6 with one underlying call; isPending true while waiting. Not tested: browsers, React concurrent, rAF wrappers, full lodash-es import. No affiliate.