perfect-debounce vs lodash.debounce vs throttle-debounce: Debounce Utilities in JavaScript (2026)
TL;DR
perfect-debounce is the UnJS debounce utility — tiny (~300B), promise-based, handles async functions correctly, returns the debounced function's result. lodash.debounce is the classic debounce — leading/trailing edge control, cancel/flush, maxWait, the most widely used debounce. throttle-debounce provides both throttle and debounce — cancel support, noTrailing/noLeading options, lightweight standalone package. In 2026: perfect-debounce for async debouncing, lodash.debounce for full-featured debouncing, throttle-debounce when you need both throttle and debounce.
Key Takeaways
- perfect-debounce: ~5M weekly downloads — UnJS, async/promise support, ~300 bytes
- lodash.debounce: ~15M weekly downloads — leading/trailing, maxWait, cancel/flush
- throttle-debounce: ~5M weekly downloads — both throttle + debounce, lightweight
- Debounce: wait until calls stop, then execute (search input)
- Throttle: execute at most once per interval (scroll, resize)
- perfect-debounce is the only one that correctly handles async return values
Debounce vs Throttle
Debounce (wait until quiet):
Input: ──x─x─x─x─────x─x────────
Output: ─────────────x──────────x──
Use: Search input, form validation, resize end
Throttle (limit frequency):
Input: ──x─x─x─x─x─x─x─x─x─x──
Output: ──x─────x─────x─────x─────
Use: Scroll handler, mousemove, API polling
perfect-debounce
perfect-debounce — async-aware debounce:
Basic usage
import { debounce } from "perfect-debounce"
// Debounce a search function:
const debouncedSearch = debounce(async (query: string) => {
const results = await fetch(`/api/search?q=${query}`)
return results.json()
}, 300)
// Each call resets the timer:
debouncedSearch("r") // Timer starts
debouncedSearch("re") // Timer resets
debouncedSearch("rea") // Timer resets
debouncedSearch("reac") // Timer resets
debouncedSearch("react") // Timer resets → fires after 300ms
Promise-based (unique feature)
import { debounce } from "perfect-debounce"
// perfect-debounce returns a promise with the result:
const debouncedFetch = debounce(async (id: string) => {
const res = await fetch(`/api/packages/${id}`)
return res.json()
}, 300)
// Callers get the result:
const result = await debouncedFetch("react")
console.log(result) // { name: "react", version: "19.0.0" }
// Multiple callers all get the same result:
const [r1, r2, r3] = await Promise.all([
debouncedFetch("react"),
debouncedFetch("react"),
debouncedFetch("react"),
])
// Only ONE fetch happens — all three get the same result
maxWait
import { debounce } from "perfect-debounce"
// maxWait — ensure execution even during continuous calls:
const debouncedLog = debounce(
async (msg: string) => {
console.log(msg)
},
300,
{ maxWait: 1000 }, // Fire at most every 1 second
)
// Continuous calls:
// 0ms: debouncedLog("a") — timer starts
// 100ms: debouncedLog("b") — timer resets
// 200ms: debouncedLog("c") — timer resets
// ...
// 1000ms: fires with "..." — maxWait reached
lodash.debounce
lodash.debounce — full-featured debounce:
Basic usage
import debounce from "lodash.debounce"
// Basic debounce:
const debouncedSearch = debounce((query: string) => {
fetch(`/api/search?q=${query}`)
}, 300)
// In a React component:
function SearchInput() {
const handleChange = debounce((e: ChangeEvent<HTMLInputElement>) => {
searchPackages(e.target.value)
}, 300)
return <input onChange={handleChange} placeholder="Search packages..." />
}
Leading and trailing edge
import debounce from "lodash.debounce"
// Trailing (default) — fires AFTER the wait:
const trailing = debounce(fn, 300)
// ──x─x─x─────→ fires here (300ms after last call)
// Leading — fires IMMEDIATELY on first call:
const leading = debounce(fn, 300, { leading: true, trailing: false })
// fires here→──x─x─x─────
// Subsequent calls within 300ms are ignored
// Both leading AND trailing:
const both = debounce(fn, 300, { leading: true, trailing: true })
// fires→──x─x─x─────→ fires again
// Fires on first call AND after 300ms of silence
maxWait
import debounce from "lodash.debounce"
// maxWait — guarantee execution during continuous calls:
const debouncedSave = debounce(
(data: string) => {
saveToServer(data)
},
1000,
{ maxWait: 5000 }, // Save at least every 5 seconds
)
// User types continuously:
// Debounce waits for 1s pause
// But maxWait ensures save happens at least every 5s
Cancel and flush
import debounce from "lodash.debounce"
const debouncedSave = debounce((data: string) => {
saveToServer(data)
}, 1000)
// Cancel pending execution:
debouncedSave("draft")
debouncedSave.cancel() // Cancels the pending save
// Flush — execute immediately:
debouncedSave("important")
debouncedSave.flush() // Executes immediately, doesn't wait
// React cleanup:
useEffect(() => {
return () => {
debouncedSave.cancel() // Clean up on unmount
}
}, [])
throttle-debounce
throttle-debounce — both utilities:
Debounce
import { debounce } from "throttle-debounce"
const debouncedSearch = debounce(300, (query: string) => {
searchPackages(query)
})
// Note: delay is the FIRST argument (different from lodash)
debouncedSearch("react")
Throttle
import { throttle } from "throttle-debounce"
// Throttle — execute at most once per interval:
const throttledScroll = throttle(200, () => {
updateScrollPosition()
})
window.addEventListener("scroll", throttledScroll)
// noTrailing — don't fire after the interval ends:
const noTrailing = throttle(200, () => {
handleResize()
}, { noTrailing: true })
// Fires immediately, then at most every 200ms
// Does NOT fire a final time after events stop
// noLeading — don't fire on the leading edge:
const noLeading = throttle(200, () => {
handleInput()
}, { noLeading: true })
// Waits 200ms before first fire
Cancel
import { debounce, throttle } from "throttle-debounce"
const debouncedFn = debounce(300, myFunction)
const throttledFn = throttle(200, myFunction)
// Cancel pending execution:
debouncedFn.cancel()
throttledFn.cancel()
Common patterns
import { debounce, throttle } from "throttle-debounce"
// Search input — debounce:
const searchInput = document.querySelector("#search")
searchInput.addEventListener("input", debounce(300, (e) => {
fetchResults(e.target.value)
}))
// Scroll position — throttle:
window.addEventListener("scroll", throttle(100, () => {
updateNavbar(window.scrollY)
}))
// Resize — debounce (only care about final size):
window.addEventListener("resize", debounce(250, () => {
recalculateLayout()
}))
// Mousemove — throttle (smooth updates):
canvas.addEventListener("mousemove", throttle(16, (e) => {
drawAtPosition(e.clientX, e.clientY)
}))
Feature Comparison
| Feature | perfect-debounce | lodash.debounce | throttle-debounce |
|---|---|---|---|
| Debounce | ✅ | ✅ | ✅ |
| Throttle | ❌ | ❌ (lodash.throttle) | ✅ |
| Promise return | ✅ | ❌ | ❌ |
| Async-aware | ✅ | ❌ | ❌ |
| Leading edge | ❌ | ✅ | ✅ |
| Trailing edge | ✅ | ✅ | ✅ |
| maxWait | ✅ | ✅ | ❌ |
| Cancel | ❌ | ✅ | ✅ |
| Flush | ❌ | ✅ | ❌ |
| Size | ~300B | ~1.5KB | ~1KB |
| TypeScript | ✅ | ✅ (@types) | ✅ |
| Weekly downloads | ~5M | ~15M | ~5M |
When to Use Each
Use perfect-debounce if:
- Debouncing async functions (fetch, API calls)
- Want the return value from the debounced function
- Need the smallest possible bundle (~300B)
- In the UnJS ecosystem
Use lodash.debounce if:
- Need leading edge, trailing edge, or both
- Want cancel and flush controls
- Need maxWait for guaranteed execution
- Already using lodash in your project
Use throttle-debounce if:
- Need both throttle AND debounce from one package
- Want noTrailing/noLeading control on throttle
- Need cancel support
- Building scroll/resize handlers alongside search debouncing
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on perfect-debounce v1.x, lodash.debounce v4.x, and throttle-debounce v5.x.
Compare utility libraries and developer tooling on PkgPulse →