docs/beyond/concepts/debouncing-throttling.mdx
What happens when a user types in a search box at 60 characters per minute? Or when they scroll through your page, triggering hundreds of events per second? Without proper handling, your application can grind to a halt, making unnecessary API calls or blocking the main thread with expensive computations.
// Without debouncing: 60 API calls per minute while typing
searchInput.addEventListener('input', (e) => {
fetchSearchResults(e.target.value) // Called on EVERY keystroke!
})
// With debouncing: 1 API call after user stops typing
searchInput.addEventListener('input', debounce((e) => {
fetchSearchResults(e.target.value) // Called once, 300ms after last keystroke
}, 300))
Debouncing and throttling are two techniques that control how often a function can execute. According to MDN, both patterns are essential for handling high-frequency events like scrolling, resizing, typing, and mouse movement without destroying your app's performance. The scroll event alone can fire hundreds of times per second on modern browsers.
<Info> **What you'll learn in this guide:** - The difference between debouncing and throttling - When to use debounce vs throttle (with decision flowchart) - How to implement both patterns from scratch - Leading edge vs trailing edge execution - Real-world use cases: search, scroll, resize, button clicks - How to use Lodash for production-ready implementations - Common mistakes and how to avoid them </Info> <Warning> **Prerequisite:** This guide assumes you understand [Closures](/concepts/scope-and-closures) and [Higher-Order Functions](/concepts/higher-order-functions). Both debounce and throttle are higher-order functions that use closures to maintain state between calls. </Warning>Debouncing delays the execution of a function until a specified time has passed since the last call. If the function is called again before the delay expires, the timer resets. The function only executes when the calls stop coming for the specified duration. Under the hood, debounce uses setTimeout to schedule the callback after the delay.
Think of debouncing like an elevator door. When someone approaches, the door stays open. If another person arrives, the timer resets and the door stays open longer. The door only closes after no one has approached for a few seconds. The elevator optimizes by waiting for all passengers before moving.
function debounce(fn, delay) {
let timeoutId
return function(...args) {
// Clear any existing timer
clearTimeout(timeoutId)
// Set a new timer
timeoutId = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
// Usage: Only search after user stops typing for 300ms
const debouncedSearch = debounce((query) => {
console.log('Searching for:', query)
fetchSearchResults(query)
}, 300)
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value)
})
Let's trace through what happens when a user types "hello" quickly:
User types: h e l l o [stops]
│ │ │ │ │ │
Time (ms): 0 50 100 150 200 500
│ │ │ │ │ │
Timer: start reset reset reset reset FIRES!
│ │ │ │ │ │
└── fn('hello') executes
Throttling ensures a function executes at most once within a specified time interval. Unlike debouncing, throttling guarantees regular execution during continuous events — it doesn't wait for events to stop.
Think of throttling like a water faucet with a flow restrictor. No matter how much you turn the handle, water only flows at a maximum rate. The restrictor ensures consistent output regardless of input pressure.
function throttle(fn, interval) {
let lastTime = 0
return function(...args) {
const now = Date.now()
// Only execute if enough time has passed
if (now - lastTime >= interval) {
lastTime = now
fn.apply(this, args)
}
}
}
// Usage: Update position at most every 100ms while scrolling
const throttledScroll = throttle(() => {
console.log('Scroll position:', window.scrollY)
updateScrollIndicator()
}, 100)
window.addEventListener('scroll', throttledScroll)
Let's trace through what happens during continuous scrolling:
Scroll events: ─●──●──●──●──●──●──●──●──●──●──●──●──●──●──●─►
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │
Time (ms): 0 10 20 30 40 50 60 70 80 90 100 110 120...
│ │ │
Executes: ✓ (first call) ✓ (100ms) ✓ (200ms)
└──────────────────────────┴──────────────┴──►
Key difference: Throttle guarantees the function runs every X milliseconds during continuous activity. Debounce waits for activity to stop.
<CardGroup cols={2}> <Card title="Throttle — MDN Glossary" icon="book" href="https://developer.mozilla.org/en-US/docs/Glossary/Throttle"> Official MDN definition of throttling with examples </Card> <Card title="Date.now() — MDN" icon="book" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now"> The timestamp API used in throttle implementations </Card> </CardGroup>Here's how they differ when handling the same stream of events:
┌─────────────────────────────────────────────────────────────────────────────┐
│ DEBOUNCE VS THROTTLE COMPARISON │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Raw Events (e.g., keystrokes, scroll): │
│ ─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●───────────●─●─●─●─●────────► │
│ └─────────────────────────────┘ └─────────┘ │
│ Burst 1 Burst 2 │
│ │
│ ─────────────────────────────────────────────────────────────────────────── │
│ │
│ DEBOUNCE (300ms): │
│ Waits for events to stop, then fires once │
│ │
│ ────────────────────────────────────●────────────────────●────────► │
│ │ │ │
│ Fires! Fires! │
│ (300ms after (300ms after │
│ last event) last event) │
│ │
│ ─────────────────────────────────────────────────────────────────────────── │
│ │
│ THROTTLE (100ms): │
│ Fires at regular intervals during activity │
│ │
│ ─●───────●───────●───────●───────●────────●───────●───────●────► │
│ │ │ │ │ │ │ │ │ │
│ 0ms 100ms 200ms 300ms 400ms ...ms ...ms ...ms │
│ │
│ Guarantees execution every 100ms while events continue │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Aspect | Debounce | Throttle |
|---|---|---|
| Executes | After events stop | During events, at intervals |
| Guarantees | Single execution per burst | Regular execution rate |
| Best for | Final value matters (search) | Continuous updates (scroll position) |
| During 1000ms of events | 1 execution (at end) | ~10 executions (every 100ms) |
Use this flowchart to decide between debounce and throttle:
┌─────────────────────────────────────────────────────────────────────────────┐
│ WHICH TECHNIQUE SHOULD I USE? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ │
│ │ You have a function │ │
│ │ being called too often │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ Do you need updates DURING activity? │ │
│ └────────────────────┬───────────────────┘ │
│ ┌───────────┴───────────┐ │
│ │ │ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ THROTTLE │ │ Do you only care │ │
│ │ │ │ about the FINAL │ │
│ │ • Scroll │ │ value? │ │
│ │ • Resize │ └──────────┬──────────┘ │
│ │ • Mouse move │ ┌────┴────┐ │
│ │ • Game loops │ YES NO │
│ │ • Progress │ │ │ │
│ │ │ ▼ ▼ │
│ └─────────────────┘ ┌────────────┐ ┌────────────┐ │
│ │ DEBOUNCE │ │ Consider │ │
│ │ │ │ both or │ │
│ │ • Search │ │ leading │ │
│ │ • Auto-save│ │ debounce │ │
│ │ • Validate │ │ │ │
│ │ • Resize │ └────────────┘ │
│ │ (final) │ │
│ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Use Case | Technique | Why |
|---|---|---|
| Search autocomplete | Debounce | Only fetch after user stops typing |
| Form validation | Debounce | Validate after user finishes input |
| Auto-save drafts | Debounce | Save after user pauses editing |
| Window resize layout | Debounce | Recalculate once at final size |
| Scroll position tracking | Throttle | Need regular position updates |
| Infinite scroll | Throttle | Check proximity to bottom regularly |
| Mouse move tooltips | Throttle | Update position smoothly |
| Rate-limited API calls | Throttle | Respect API rate limits |
| Button click (prevent double) | Debounce (leading) | Execute first click, ignore rapid repeats |
| Live preview | Throttle | Show changes without lag |
Both debounce and throttle can execute on the leading edge (immediately on first call) or trailing edge (after delay/at end of interval). Some implementations support both.
The function executes after the delay/interval. This is the default behavior shown above.
// Trailing debounce: executes AFTER user stops typing
const trailingDebounce = debounce(search, 300)
// Timeline: type "hi" → wait 300ms → search("hi") executes
The function executes immediately on the first call, then ignores subsequent calls until the delay expires.
function debounceLeading(fn, delay) {
let timeoutId
return function(...args) {
// Execute immediately if no pending timeout
if (!timeoutId) {
fn.apply(this, args)
}
// Clear and reset the timeout
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null // Allow next leading call
}, delay)
}
}
// Usage: Prevent double-click on submit button
const handleSubmit = debounceLeading(() => {
console.log('Form submitted!')
submitForm()
}, 1000)
submitButton.addEventListener('click', handleSubmit)
// First click: submits immediately
// Rapid clicks: ignored for 1 second
function throttleLeading(fn, interval) {
let lastTime = 0
return function(...args) {
const now = Date.now()
if (now - lastTime >= interval) {
lastTime = now
fn.apply(this, args)
}
}
}
// This is actually the same as our basic throttle!
// Throttle naturally executes on leading edge
For maximum responsiveness, execute on both leading AND trailing edges:
function debounceBothEdges(fn, delay) {
let timeoutId
let lastCallTime = 0
return function(...args) {
const now = Date.now()
const timeSinceLastCall = now - lastCallTime
// Leading edge: execute if enough time has passed
if (timeSinceLastCall >= delay) {
fn.apply(this, args)
}
lastCallTime = now
// Trailing edge: also execute after delay
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fn.apply(this, args)
lastCallTime = Date.now()
}, delay)
}
}
Here are more robust implementations with additional features:
function debounce(fn, delay, options = {}) {
let timeoutId
let lastArgs
let lastThis
const { leading = false, trailing = true } = options
function debounced(...args) {
lastArgs = args
lastThis = this
const invokeLeading = leading && !timeoutId
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
if (trailing && lastArgs) {
fn.apply(lastThis, lastArgs)
lastArgs = null
lastThis = null
}
}, delay)
if (invokeLeading) {
fn.apply(this, args)
}
}
debounced.cancel = function() {
clearTimeout(timeoutId)
timeoutId = null
lastArgs = null
lastThis = null
}
debounced.flush = function() {
if (timeoutId && lastArgs) {
fn.apply(lastThis, lastArgs)
debounced.cancel()
}
}
return debounced
}
// Usage
const debouncedSave = debounce(saveDocument, 1000, { leading: true, trailing: true })
// Cancel pending execution
debouncedSave.cancel()
// Execute immediately
debouncedSave.flush()
function throttle(fn, interval, options = {}) {
let lastTime = 0
let timeoutId
let lastArgs
let lastThis
const { leading = true, trailing = true } = options
function throttled(...args) {
const now = Date.now()
const timeSinceLastCall = now - lastTime
lastArgs = args
lastThis = this
// Leading edge
if (timeSinceLastCall >= interval) {
if (leading) {
lastTime = now
fn.apply(this, args)
}
}
// Schedule trailing edge
if (trailing) {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (Date.now() - lastTime >= interval && lastArgs) {
lastTime = Date.now()
fn.apply(lastThis, lastArgs)
lastArgs = null
lastThis = null
}
}, interval - timeSinceLastCall)
}
}
throttled.cancel = function() {
clearTimeout(timeoutId)
lastTime = 0
timeoutId = null
lastArgs = null
lastThis = null
}
return throttled
}
For production applications, use battle-tested libraries like Lodash. With over 30 million weekly npm downloads, Lodash's debounce and throttle implementations handle edge cases, provide TypeScript types, and are thoroughly tested across thousands of production applications.
# Full library
npm install lodash
# Or just the functions you need
npm install lodash.debounce lodash.throttle
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'
// Debounce with options
const debouncedSearch = debounce(search, 300, {
leading: false, // Don't execute on first call
trailing: true, // Execute after delay (default)
maxWait: 1000 // Maximum time to wait (forces execution)
})
// Throttle with options
const throttledScroll = throttle(updateScrollPosition, 100, {
leading: true, // Execute on first call (default)
trailing: true // Also execute at end of interval (default)
})
// Cancel pending execution
debouncedSearch.cancel()
// Execute immediately
debouncedSearch.flush()
Lodash's debounce has a powerful maxWait option that sets a maximum time the function can be delayed:
import debounce from 'lodash/debounce'
// Search after typing stops, BUT at least every 2 seconds
const debouncedSearch = debounce(search, 300, {
maxWait: 2000 // Force execution after 2 seconds of continuous typing
})
This is essentially debounce + throttle combined. Useful when you want responsiveness during long bursts of activity.
<Tip> **Fun fact:** Lodash's `throttle` is actually implemented using `debounce` with the `maxWait` option set equal to the wait time. Check the [source code](https://github.com/lodash/lodash/blob/main/src/throttle.ts)! </Tip>import debounce from 'lodash/debounce'
const searchInput = document.getElementById('search')
const resultsContainer = document.getElementById('results')
async function fetchResults(query) {
if (!query.trim()) {
resultsContainer.innerHTML = ''
return
}
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
const results = await response.json()
renderResults(results)
} catch (error) {
console.error('Search failed:', error)
}
}
// Only search 300ms after user stops typing
const debouncedFetch = debounce(fetchResults, 300)
searchInput.addEventListener('input', (e) => {
debouncedFetch(e.target.value)
})
import throttle from 'lodash/throttle'
function checkScrollPosition() {
const scrollPosition = window.scrollY + window.innerHeight
const documentHeight = document.documentElement.scrollHeight
// Load more when within 200px of bottom
if (documentHeight - scrollPosition < 200) {
loadMoreContent()
}
}
// Check position every 100ms while scrolling
const throttledCheck = throttle(checkScrollPosition, 100)
window.addEventListener('scroll', throttledCheck)
// Cleanup on unmount
function cleanup() {
window.removeEventListener('scroll', throttledCheck)
throttledCheck.cancel()
}
import debounce from 'lodash/debounce'
function recalculateLayout() {
const width = window.innerWidth
const height = window.innerHeight
// Expensive layout calculations
updateGridColumns(width)
resizeCharts(width, height)
repositionElements()
}
// Only recalculate after user stops resizing
const debouncedResize = debounce(recalculateLayout, 250)
window.addEventListener('resize', debouncedResize)
import debounce from 'lodash/debounce'
const form = document.getElementById('checkout-form')
async function submitOrder(formData) {
const response = await fetch('/api/orders', {
method: 'POST',
body: formData
})
if (response.ok) {
window.location.href = '/order-confirmation'
}
}
// Execute immediately, ignore clicks for 2 seconds
const debouncedSubmit = debounce(submitOrder, 2000, {
leading: true,
trailing: false
})
form.addEventListener('submit', (e) => {
e.preventDefault()
debouncedSubmit(new FormData(form))
})
import throttle from 'lodash/throttle'
const tooltip = document.getElementById('tooltip')
function updateTooltipPosition(x, y) {
tooltip.style.left = `${x + 10}px`
tooltip.style.top = `${y + 10}px`
}
// Update tooltip position every 16ms (60fps)
const throttledUpdate = throttle(updateTooltipPosition, 16)
document.addEventListener('mousemove', (e) => {
throttledUpdate(e.clientX, e.clientY)
})
For visual updates tied to rendering (animations, scroll effects), requestAnimationFrame is often better than throttle. As web.dev's rendering performance guide explains, rAF syncs with the browser's repaint cycle (typically 60fps ≈ 16ms) and is scheduled by the event loop as a special render-related callback.
function throttleWithRAF(fn) {
let ticking = false
let lastArgs
return function(...args) {
lastArgs = args
if (!ticking) {
ticking = true
requestAnimationFrame(() => {
fn.apply(this, lastArgs)
ticking = false
})
}
}
}
// Usage: Smooth scroll-linked animations
const updateScrollAnimation = throttleWithRAF(() => {
const scrollPercent = window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)
progressBar.style.width = `${scrollPercent * 100}%`
parallaxElement.style.transform = `translateY(${scrollPercent * 100}px)`
})
window.addEventListener('scroll', updateScrollAnimation)
When to use rAF vs throttle:
| Use rAF when... | Use throttle when... |
|---|---|
| Animating DOM elements | Rate-limiting API calls |
| Scroll-linked visual effects | Infinite scroll loading |
| Canvas/WebGL rendering | Analytics event tracking |
| Parallax effects | Form validation |
// ❌ WRONG: Creates a new debounced function on every call
element.addEventListener('input', (e) => {
debounce(handleInput, 300)(e) // This doesn't work!
})
// ✓ CORRECT: Create once, reuse
const debouncedHandler = debounce(handleInput, 300)
element.addEventListener('input', debouncedHandler)
// ❌ WRONG: Memory leak in React/Vue/etc.
useEffect(() => {
const handler = throttle(handleScroll, 100)
window.addEventListener('scroll', handler)
}, [])
// ✓ CORRECT: Clean up on unmount
useEffect(() => {
const handler = throttle(handleScroll, 100)
window.addEventListener('scroll', handler)
return () => {
window.removeEventListener('scroll', handler)
handler.cancel() // Cancel any pending calls
}
}, [])
// ❌ WRONG: Debounce for scroll position tracking
// User won't see smooth updates, only final position
window.addEventListener('scroll', debounce(updatePosition, 100))
// ✓ CORRECT: Throttle for continuous visual updates
window.addEventListener('scroll', throttle(updatePosition, 100))
// ❌ WRONG: Throttle for search autocomplete
// Unnecessary API calls while user is still typing
input.addEventListener('input', throttle(search, 300))
// ✓ CORRECT: Debounce for search (only when typing stops)
input.addEventListener('input', debounce(search, 300))
this Context// ❌ WRONG: Arrow function preserves wrong `this`
class SearchComponent {
constructor() {
this.query = ''
}
handleInput = debounce(() => {
console.log(this.query) // Works, but...
}, 300)
}
// ❌ WRONG: Method loses `this` when passed as callback
class SearchComponent {
handleInput() {
console.log(this.query) // `this` is undefined!
}
}
const component = new SearchComponent()
input.addEventListener('input', debounce(component.handleInput, 300))
// ✓ CORRECT: Bind the method
input.addEventListener('input', debounce(component.handleInput.bind(component), 300))
// ✓ ALSO CORRECT: Wrap in arrow function
input.addEventListener('input', debounce((e) => component.handleInput(e), 300))
// ❌ TOO SHORT: Defeats the purpose
debounce(search, 50) // Still makes many API calls
// ❌ TOO LONG: Feels unresponsive
debounce(search, 1000) // User waits 1 second for results
// ✓ GOOD: Balance between responsiveness and efficiency
debounce(search, 250) // 250-400ms is typical for search
throttle(scroll, 100) // 100-150ms for scroll (smooth but efficient)
Debounce waits for silence — It delays execution until events stop coming for a specified duration. Use it when you only care about the final value.
Throttle maintains rhythm — It ensures execution happens at most once per interval, even during continuous events. Use it when you need regular updates.
Leading vs trailing — Leading executes immediately on first call; trailing executes after the delay. You can use both for maximum responsiveness.
Use Lodash in production — Battle-tested implementations with TypeScript types, cancel methods, and edge case handling.
Create debounced/throttled functions once — Don't create them inside event handlers or render functions.
Always clean up — Cancel pending executions and remove event listeners when components unmount.
requestAnimationFrame for animations — For visual updates, rAF syncs with the browser's repaint cycle for smoother results.
Choose the right delay — 250-400ms for search/typing, 100-150ms for scroll/resize, 16ms for animations.
Closures make it work — Both techniques use closures to maintain state (timers, timestamps) between function calls.
Test your implementation — Verify the behavior matches your expectations, especially edge cases like rapid bursts and cleanup.
</Info>- **Debounce** waits for a pause in events before executing. The function only runs once after events stop coming for the specified delay.
- **Throttle** executes at regular intervals during continuous events. It guarantees the function runs at most once per specified interval, providing regular updates.
**Example:** If events fire continuously for 1 second:
- Debounce (300ms): 1 execution (after events stop + 300ms)
- Throttle (100ms): ~10 executions (every 100ms)
Leading edge debounce executes immediately on the first call, then ignores subsequent calls until the delay expires. Use it when:
1. **Preventing double-clicks** — Submit form on first click, ignore rapid additional clicks
2. **Immediate feedback** — Show something instantly, but don't repeat
3. **First interaction matters** — Track first button press, not every press
```javascript
const preventDoubleClick = debounce(submitForm, 1000, {
leading: true,
trailing: false
})
```
Creating a debounced function inside an event handler creates a **new function every time the event fires**. Each new function has its own separate timer, so debouncing never actually works:
```javascript
// ❌ WRONG - new debounced function each time
input.addEventListener('input', (e) => {
debounce(search, 300)(e.target.value)
// Timer 1, Timer 2, Timer 3... none wait for each other
})
// ✓ CORRECT - same debounced function reused
const debouncedSearch = debounce(search, 300)
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value)
// Same timer gets reset each time
})
```
The `maxWait` option sets a maximum time a debounced function can be delayed. Even if events keep coming, the function will execute after `maxWait` milliseconds.
```javascript
const debouncedSearch = debounce(search, 300, {
maxWait: 2000 // Force execution after 2 seconds
})
```
This is useful for long typing sessions — you still get the debounce behavior, but users see results at least every 2 seconds. It's essentially debounce + throttle combined.
Fun fact: Lodash's `throttle` is implemented using `debounce` with `maxWait` equal to the wait time!
Use `requestAnimationFrame` when you're doing **visual updates** that need to sync with the browser's repaint cycle:
- Scroll-linked animations
- Parallax effects
- Canvas/WebGL rendering
- DOM element transformations
```javascript
// rAF syncs with 60fps refresh rate
const throttledWithRAF = (fn) => {
let ticking = false
return (...args) => {
if (!ticking) {
requestAnimationFrame(() => {
fn(...args)
ticking = false
})
ticking = true
}
}
}
```
Use throttle for non-visual tasks: API calls, analytics tracking, loading content, validation.
Both debounce and throttle are **higher-order functions** that return a new function. The returned function uses **closures** to remember state between calls:
```javascript
function debounce(fn, delay) {
let timeoutId // ← Closure variable, persists between calls
return function(...args) {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn.apply(this, args), delay)
// timeoutId is remembered from the previous call
}
}
```
The closure allows the returned function to:
- Remember the `timeoutId` from previous calls (to clear it)
- Track `lastTime` for throttle calculations
- Store pending `args` and `this` context
Without closures, each call would have no memory of previous calls.