Skip to main content

perfect-debounce vs lodash.debounce vs throttle-debounce: Debounce Utilities in JavaScript (2026)

·PkgPulse Team

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

Featureperfect-debouncelodash.debouncethrottle-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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.