Skip to main content

Guide

Debounce Libraries 2026: perfect vs lodash vs throttle

Compare perfect-debounce, lodash.debounce, and throttle-debounce. Async promise support, leading/trailing edges, React patterns, and bundle size tradeoffs.

·PkgPulse Team·
0

TL;DR

perfect-debounce (~5M weekly downloads) is the UnJS debounce utility — tiny (~300B), promise-aware, returns the debounced function's result so async callers can actually await it. lodash.debounce (~15M weekly downloads) is the battle-tested choice — leading/trailing edge control, cancel/flush, and maxWait for guaranteed execution under continuous input. throttle-debounce (~5M weekly downloads) provides both throttle and debounce from a single package with noTrailing/noLeading options and cancel support. In 2026: perfect-debounce for async search and API debouncing where the return value matters, lodash.debounce for full-featured debouncing with precise edge control, throttle-debounce when you need both patterns without importing two packages.

Quick Comparison

perfect-debouncelodash.debouncethrottle-debounce
Weekly downloads~5M~15M~5M
Size (minified)~300B~1.5KB~1KB
Dependencies000
Promise returnYesNoNo
Async-awareYesNoNo
Leading edgeNoYesYes
Trailing edgeYesYesYes
maxWaitYesYesNo
CancelNoYesYes
FlushNoYesNo
Throttle includedNoNoYes
Argument order(fn, delay)(fn, delay)(delay, fn)

Debounce vs Throttle: The Core Distinction

JavaScript's event loop is single-threaded, which means event handlers that trigger expensive operations — API calls, DOM recalculations, analytics events — can pile up and degrade user experience in ways that are surprisingly difficult to diagnose. The browser queues events faithfully, and if your handler does real work, you can easily fire dozens of fetch calls in the time it takes a user to type a search query.

Debouncing and throttling are the two fundamental patterns for managing this problem, and they solve different versions of it. Debounce accumulates a stream of calls and fires exactly once after the stream goes quiet — after a user pauses typing, after a resize ends, after a window stops dragging. Throttle allows a maximum of one call per interval during continuous activity — during an active scroll, during mousemove, during real-time canvas drawing.

The semantic distinction matters when choosing a library: debounce fires once after activity stops, throttle fires at a steady rate during continuous activity.

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

With fast typing across a six-character query, a naively wired search handler fires six API requests — most abandoned before completing, potentially returning out-of-order results that overwrite each other. Debouncing waits until the user pauses. Set a 300ms delay and most users trigger a single request per search, no matter how fast they type.


perfect-debounce

perfect-debounce comes from the UnJS ecosystem — the same family of utilities as ofetch, defu, ufo, and consola. Its defining feature is that it treats the wrapped function as an async operation with a meaningful return value, not a fire-and-forget void. When the debounce timer fires, the resulting promise resolves to whatever the underlying function returned, and all callers that were waiting on the debounced function share that single promise.

This design is straightforward to explain but surprisingly absent from most debounce implementations: if you debounce an async function and call it three times in 300ms, all three callers get the same resolved value when the single underlying invocation completes. No duplicate requests, no dropped results.

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 return value

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 is the standalone package extracted from the full lodash library. It's the most downloaded debounce implementation in the JavaScript ecosystem, having been the de facto standard for over a decade. Its API is comprehensive: trailing/leading edge control, maxWait for guaranteed execution during continuous input, cancel to abandon a pending invocation, and flush to execute immediately without waiting.

The leading edge option is lodash.debounce's most distinctive feature. By default, debounce fires on the trailing edge — after the wait period expires. With { leading: true, trailing: false }, it fires immediately on the first call in a burst and ignores subsequent calls during the wait period. This is useful for submit button debouncing, where you want to respond to the first click immediately but ignore rapid double-clicks.

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
  }
}, [])

The cancel() method is essential for React component cleanup. Without it, a debounced function that calls setState can fire after the component unmounts, producing the "Can't perform a React state update on an unmounted component" warning. Always cancel in the useEffect cleanup function.


throttle-debounce

throttle-debounce solves a different problem than the other two: it provides both throttle and debounce from a single package. For applications that need scroll throttling in the same codebase as search debouncing, importing one package instead of two is a practical simplification.

The most important thing to know about throttle-debounce is the argument order. Unlike lodash and perfect-debounce where the function comes first (debounce(fn, delay)), throttle-debounce puts the delay first: debounce(delay, fn). This reversal trips up developers switching between libraries and TypeScript will catch it if strict types are enabled — but it's the most common source of bugs when adopting this library.

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)
}))

React Integration Patterns

How you integrate debounce functions into React components is often as important as which library you choose. The most common mistake is creating a new debounced function on every render, which resets the timer on every re-render and makes the debounce effectively never fire.

Creating debounce(fn, 300) directly in a component body executes that call on every render. For a controlled input that triggers a re-render on each keystroke, this means the debounce instance is recreated with every character typed — the timer starts, the component re-renders, the instance is replaced, the timer resets, repeat. The debounce never fires.

The correct pattern stores the debounced function in a useRef so it survives re-renders:

import { useRef, useEffect } from "react"
import debounce from "lodash.debounce"

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const debouncedSearch = useRef(
    debounce((query: string) => {
      onSearch(query)
    }, 300)
  )

  // Cancel on unmount to prevent setState after unmount:
  useEffect(() => {
    return () => debouncedSearch.current.cancel()
  }, [])

  return (
    <input
      onChange={(e) => debouncedSearch.current(e.target.value)}
      placeholder="Search..."
    />
  )
}

For perfect-debounce, the same useRef pattern applies, but the cleanup is different since perfect-debounce has no cancel() method. Instead, use a isMounted ref to discard results that arrive after unmount:

import { useRef, useEffect, useState } from "react"
import { debounce } from "perfect-debounce"

function SearchInput() {
  const [results, setResults] = useState([])
  const isMounted = useRef(true)

  const debouncedSearch = useRef(
    debounce(async (query: string) => {
      const data = await fetchSearch(query)
      if (isMounted.current) setResults(data)
    }, 300)
  )

  useEffect(() => {
    return () => { isMounted.current = false }
  }, [])

  return <input onChange={(e) => debouncedSearch.current(e.target.value)} />
}

React Query and SWR users should debounce the query key rather than the fetch function. Keep a local state for the raw input value and a separate debounced state that the query hook depends on. This way the debounce logic lives in the state layer, not the fetch layer, and React Query's cache and deduplication work correctly.


Vue and Svelte Patterns

In Vue's Composition API, debounced handlers should be created in setup() using ref or defined as module-level constants, not as inline arrow functions in templates. Vue re-evaluates template expressions on reactive data changes, so an inline debounce(fn, 300) call in a template creates a fresh debounced function each time the template updates, producing the same re-render bug as the React case.

// Vue Composition API:
import { ref, watchEffect, onUnmounted } from "vue"
import debounce from "lodash.debounce"

export default {
  setup() {
    const query = ref("")
    const results = ref([])

    const debouncedSearch = debounce(async (q: string) => {
      results.value = await fetchSearch(q)
    }, 300)

    watchEffect(() => {
      if (query.value) debouncedSearch(query.value)
    })

    onUnmounted(() => debouncedSearch.cancel())

    return { query, results }
  }
}

In Svelte, the cleanest pattern for debounced event handlers is to define them in the <script> block rather than inline in the template. Svelte's reactivity model is compile-time rather than runtime, but the same principle applies: the debounced function identity should be stable across reactive updates.


Server-Side Debouncing in Node.js

Debouncing is often thought of as a browser pattern, but it has genuine use cases on the server. Cache invalidation is the clearest example: when a database record is updated, you may want to invalidate related caches after a brief debounce window to avoid invalidating and rebuilding the cache dozens of times per second during a bulk update operation. Similarly, log aggregation systems often debounce write operations to batch multiple log entries into a single disk write.

import { debounce } from "perfect-debounce"

// Debounce cache invalidation — batch rapid updates:
const invalidateProductCache = debounce(async (productId: string) => {
  await redis.del(`product:${productId}`)
  await redis.del(`product:${productId}:variants`)
}, 500, { maxWait: 2000 })

// A bulk import that updates 100 products only triggers
// one cache invalidation per product, not 100:
for (const product of updatedProducts) {
  await db.product.update({ where: { id: product.id }, data: product })
  invalidateProductCache(product.id)
}

API rate limiting at the application layer is another server-side use case, though throttle is usually the better tool here — you want to limit request frequency, not wait until requests stop. For write-through caches, webhook delivery, and audit log flushes, debounce is the right pattern.


Testing Debounced Functions

Testing debounced functions requires fake timer support because real debounce delays make tests slow and non-deterministic. Both Jest and Vitest provide useFakeTimers() that replaces the global timer functions with controllable mocks, allowing you to advance time programmatically.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import debounce from "lodash.debounce"

describe("debounced search", () => {
  beforeEach(() => vi.useFakeTimers())
  afterEach(() => vi.useRealTimers())

  it("fires once after delay", () => {
    const fn = vi.fn()
    const debouncedFn = debounce(fn, 300)

    debouncedFn("a")
    debouncedFn("b")
    debouncedFn("c")

    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(300)

    expect(fn).toHaveBeenCalledTimes(1)
    expect(fn).toHaveBeenCalledWith("c")  // Last call wins
  })

  it("cancel prevents execution", () => {
    const fn = vi.fn()
    const debouncedFn = debounce(fn, 300)

    debouncedFn("test")
    debouncedFn.cancel()
    vi.advanceTimersByTime(300)

    expect(fn).not.toHaveBeenCalled()
  })
})

For perfect-debounce, which returns promises, you need to combine fake timers with async test utilities:

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { debounce } from "perfect-debounce"

describe("perfect-debounce async", () => {
  beforeEach(() => vi.useFakeTimers())
  afterEach(() => vi.useRealTimers())

  it("returns the async result to all callers", async () => {
    const fn = vi.fn().mockResolvedValue({ name: "react" })
    const debouncedFn = debounce(fn, 300)

    const p1 = debouncedFn("react")
    const p2 = debouncedFn("react")

    vi.advanceTimersByTime(300)
    const [r1, r2] = await Promise.all([p1, p2])

    expect(fn).toHaveBeenCalledTimes(1)
    expect(r1).toEqual({ name: "react" })
    expect(r2).toEqual({ name: "react" })
  })
})

Async Debouncing: Why perfect-debounce Stands Out

The fundamental problem with lodash.debounce and most debounce implementations is that they were designed with synchronous functions in mind. When you debounce a function with lodash, the returned wrapper function returns void — it does not propagate the return value back to the caller. This is a deliberate design choice that works perfectly for fire-and-forget use cases: analytics events, DOM updates, autosave, and similar patterns where no one is waiting on the result.

The problem surfaces when your debounced function is async and callers actually need the result. If you debounce an API search function with lodash and multiple parts of your UI call the debounced version waiting for results, they each get undefined. Each caller has to implement its own mechanism to receive the data, typically through shared state or a callback.

perfect-debounce solves this with promise deduplication. When multiple callers invoke the debounced function before the timer fires, they all receive the same promise. Only one actual function call is made, but every caller that was waiting receives the resolved value when it arrives. This is the right behavior for React components with search-as-you-type: multiple renders may call the debounced search function, but they all share a single inflight request.


Bundle Size Analysis

Bundle size matters for client-side JavaScript, and these three libraries package their code differently. perfect-debounce is a single ~300B ESM module — it tree-shakes to essentially nothing and has no sub-dependencies. For projects optimizing Core Web Vitals or building mobile-first applications, it's the obvious choice when its feature set is sufficient.

lodash.debounce is the standalone lodash method package at approximately 1.5KB minified. If you import from the full lodash package (import debounce from 'lodash/debounce'), the cost is similar, but you must ensure your bundler tree-shakes lodash properly. The lodash-es package is the ESM version, which tree-shakes cleanly with modern bundlers.

throttle-debounce is approximately 1KB minified and ships both CommonJS and ESM modules. For projects that need both throttle and debounce, importing from throttle-debounce costs less than importing lodash.debounce plus lodash.throttle separately — you get both for ~1KB instead of ~3KB combined.

The real-world impact is small in absolute terms — 1KB vs 0.3KB is negligible compared to a typical React bundle. But for utility libraries that ship as part of a published npm package consumed by other projects, choosing the smallest option reduces downstream bundle costs for all consumers.


Feature Comparison

Featureperfect-debouncelodash.debouncethrottle-debounce
DebounceYesYesYes
ThrottleNoNo (lodash.throttle)Yes
Promise returnYesNoNo
Async-awareYesNoNo
Leading edgeNoYesYes
Trailing edgeYesYesYes
maxWaitYesYesNo
CancelNoYesYes
FlushNoYesNo
Size~300B~1.5KB~1KB
TypeScriptYesYes (@types)Yes
Weekly downloads~5M~15M~5M

When to Use Each

Use perfect-debounce if:

  • Debouncing async functions where callers need the return value
  • Want the smallest possible bundle (~300B)
  • Building search-as-you-type with React, Vue, or Svelte
  • In the UnJS ecosystem (Nuxt, H3, Nitro)

Use lodash.debounce if:

  • Need leading edge control (fire immediately on first call)
  • Want cancel() for cleanup on component unmount
  • Need flush() to execute immediately without waiting
  • Already using lodash in your project

Use throttle-debounce if:

  • Need both throttle and debounce from one package
  • Want noTrailing/noLeading options on throttle
  • Building scroll/resize handlers alongside search debouncing
  • Note: delay comes first in the argument order

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 →

See also: Lodash vs Underscore and Lodash vs Ramda, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.