Intermediate0 questionsFull Guide

JavaScript Debounce & Throttle Interview Questions

Debounce and throttle are the most common JavaScript performance implementation questions. Learn the difference and build both from scratch.

The Mental Model

Picture an elevator. People keep pressing the button. The elevator doesn't leave the moment the first person presses it — it waits. If someone else presses within the next few seconds, the timer resets. Only after nobody has pressed for a few seconds does it finally close the doors and go. That's debounce — wait for the activity to stop, then act. Now picture a turnstile at a subway entrance. No matter how fast you push against it, it only lets one person through per second. The rate is fixed. You can push faster, but the turnstile doesn't speed up. People get queued or dropped, but the throughput stays controlled. That's throttle — act at most once per interval, regardless of how many triggers occur. Both solve the same root problem: an event fires far more often than you can or should respond to it. A user typing fires keydown/keyup dozens of times per second. A scroll event fires hundreds of times per scroll. A resize event fires continuously during window drag. Neither debounce nor throttle is universally better — they answer different questions. Debounce: "when has the activity finally stopped?" Throttle: "how often should I respond while activity is ongoing?"

The Explanation

Debounce — built from scratch

function debounce(fn, delay) {
  let timerId = null

  return function(...args) {
    // Cancel any pending execution
    clearTimeout(timerId)

    // Schedule a new one
    timerId = setTimeout(() => {
      fn.apply(this, args)
      timerId = null
    }, delay)
  }
}

// Usage:
const handleSearch = debounce((query) => {
  fetch(`/api/search?q=${query}`).then(renderResults)
}, 300)

searchInput.addEventListener('input', (e) => handleSearch(e.target.value))
// User types "javascript" — 9 keystrokes, but only 1 API call
// The call fires 300ms after the last keystroke

Debounce with immediate option

// Leading edge: fire IMMEDIATELY on first call, then ignore until quiet
// Trailing edge (default): fire AFTER quiet period ends
// Leading + trailing: fire on both

function debounce(fn, delay, { leading = false, trailing = true } = {}) {
  let timerId = null
  let lastArgs = null

  return function(...args) {
    const shouldCallNow = leading && !timerId
    lastArgs = args

    clearTimeout(timerId)
    timerId = setTimeout(() => {
      if (trailing && lastArgs) {
        fn.apply(this, lastArgs)
      }
      timerId = null
      lastArgs = null
    }, delay)

    if (shouldCallNow) {
      fn.apply(this, args)
    }
  }
}

// Leading debounce — great for button click handlers
// First click fires immediately, subsequent rapid clicks ignored until quiet
const handleSubmit = debounce(submitForm, 1000, { leading: true, trailing: false })
submitButton.addEventListener('click', handleSubmit)
// Prevents double-submit on fast double-click, but feels instant on first click

Throttle — built from scratch

function throttle(fn, interval) {
  let lastCallTime = 0

  return function(...args) {
    const now = Date.now()

    if (now - lastCallTime >= interval) {
      lastCallTime = now
      fn.apply(this, args)
    }
    // Calls within the interval are silently dropped
  }
}

// Usage:
const handleScroll = throttle(() => {
  updateScrollIndicator()
  lazyLoadImages()
}, 100)

window.addEventListener('scroll', handleScroll)
// Scroll fires ~100 times per second
// handleScroll executes at most 10 times per second (every 100ms)

Throttle with trailing call — never miss the last event

// Problem with basic throttle: if the last event falls in the ignored window,
// the final position/state is never processed

function throttle(fn, interval) {
  let lastCallTime = 0
  let trailingTimer = null

  return function(...args) {
    const now = Date.now()
    const remaining = interval - (now - lastCallTime)

    if (remaining <= 0) {
      // Enough time has passed — execute immediately
      clearTimeout(trailingTimer)
      trailingTimer = null
      lastCallTime = now
      fn.apply(this, args)
    } else if (!trailingTimer) {
      // Schedule a trailing call for the end of the interval
      trailingTimer = setTimeout(() => {
        lastCallTime = Date.now()
        trailingTimer = null
        fn.apply(this, args)
      }, remaining)
    }
  }
}
// Now the final scroll/resize position is always processed

requestAnimationFrame throttle — the visual update pattern

// For visual updates, throttle to the display frame rate — not a fixed ms interval
function rafThrottle(fn) {
  let frameRequested = false

  return function(...args) {
    if (!frameRequested) {
      frameRequested = true
      requestAnimationFrame(() => {
        fn.apply(this, args)
        frameRequested = false
      })
    }
  }
}

// Perfect for: scroll-linked animations, sticky header position, parallax effects
const updateParallax = rafThrottle(() => {
  const scrollY = window.scrollY
  heroImage.style.transform = `translateY(${scrollY * 0.5}px)`
})

window.addEventListener('scroll', updateParallax)
// Runs at most once per animation frame (~60fps)
// No visual intermediate states are skipped — always uses the latest scroll position

Debounce vs Throttle — choosing the right tool

// DEBOUNCE — when you only care about the FINAL value after activity stops
const searchInput = debounce((value) => {
  fetchSuggestions(value)  // only run when user stops typing
}, 300)

const resizeHandler = debounce(() => {
  recalculateLayout()      // only run when resize is done
}, 200)

const autoSave = debounce(() => {
  saveToServer(editorContent)  // save 2 seconds after last edit
}, 2000)

// THROTTLE — when you need REGULAR updates during ongoing activity
const scrollHandler = throttle(() => {
  updateScrollProgress()   // update every 100ms while scrolling
}, 100)

const mouseMoveHandler = throttle((e) => {
  updateCustomCursor(e)    // track cursor position at 30fps
}, 33)

const logActivity = throttle(() => {
  sendHeartbeat()          // send heartbeat at most once per 10 seconds
}, 10000)

// NEITHER — when every event matters
button.addEventListener('click', handleClick)  // don't throttle discrete clicks
input.addEventListener('focus', handleFocus)   // don't debounce focus events

Cancellation — clearing pending calls

function debounce(fn, delay) {
  let timerId = null

  function debounced(...args) {
    clearTimeout(timerId)
    timerId = setTimeout(() => {
      fn.apply(this, args)
      timerId = null
    }, delay)
  }

  // Cancel pending execution
  debounced.cancel = function() {
    clearTimeout(timerId)
    timerId = null
  }

  // Check if a call is pending
  debounced.pending = function() {
    return timerId !== null
  }

  return debounced
}

// Usage:
const debouncedSearch = debounce(search, 300)
searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value))

// When component unmounts / user navigates away:
debouncedSearch.cancel()  // prevent stale API call from firing after navigation

React hook implementations

// useDebounce — debounce a value
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay)
    return () => clearTimeout(timer)  // cleanup on value change or unmount
  }, [value, delay])

  return debouncedValue
}

// Usage:
function SearchComponent() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 300)

  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery)
  }, [debouncedQuery])  // only fetches 300ms after user stops typing

  return  setQuery(e.target.value)} />
}

// useThrottle hook:
function useThrottle(fn, interval) {
  const fnRef = useRef(fn)
  useEffect(() => { fnRef.current = fn }, [fn])

  return useCallback(
    throttle((...args) => fnRef.current(...args), interval),
    [interval]  // stable reference — throttle function not recreated on renders
  )
}

Common Misconceptions

⚠️

Many devs think debounce and throttle are the same thing with different timing — but actually they answer different questions. Debounce asks "has the activity stopped?" and fires after a quiet period. Throttle asks "how often should I respond?" and fires on a regular schedule regardless of when the last trigger was. Using debounce on a scroll handler means the handler only fires after the user stops scrolling — great for some cases, wrong for anything that should update during the scroll.

⚠️

Many devs think debounce means the call is delayed by the specified time — but actually debounce means the call is delayed by the specified time AFTER THE LAST TRIGGER. If a user types a character, waits 200ms, types another, and waits 200ms, the debounced function never fires because a new 200ms countdown starts each time. The delay is always from the last event, not from the first. The total delay could be much longer than the debounce duration.

⚠️

Many devs think throttle guarantees the last event is always processed — but actually basic throttle (timestamp comparison only) silently drops events that fall in the cooldown window. If a user scrolls to position 1000 but the last throttled call only saw position 850, the final scroll position is never processed. A throttle implementation with a trailing call (using setTimeout for the remaining window) is needed to guarantee the last event is eventually processed.

⚠️

Many devs think they should always use lodash's debounce/throttle rather than writing their own — but actually for many use cases a simple custom implementation is preferable. Lodash's debounce is ~100 lines with many options most code never uses. A 10-line custom debounce tailored to one use case is more readable, has no dependency, and is easier to modify. The right choice depends on whether you need lodash's edge cases (leading/trailing options, cancel, flush, maxWait).

⚠️

Many devs think debouncing in React should be done with useCallback wrapping a debounced function — but actually useMemo or useRef is the right hook for a debounced function, because you need a stable reference to the same debounced instance across renders. If you recreate the debounced function inside useCallback with dependencies, you may reset the internal timer on every render, defeating the purpose. The debounced function should be created once and referenced via useRef.

⚠️

Many devs think throttle with a short interval is equivalent to debounce — but actually they behave differently even with the same timing. Throttle always fires on the first trigger (at t=0), then gates subsequent calls. Debounce never fires on the first trigger — it always waits for the quiet period. At the same interval, throttle is more responsive (immediate first call) but fires more often; debounce is more conservative (waits for silence) but gives you the final value.

Where You'll See This in Real Code

Search-as-you-type autocomplete — every major search interface (Google, GitHub, Algolia) uses debounce on the search input. Firing an API request on every keystroke would mean "javascript" triggers 10 sequential requests, with the results arriving out of order and the last one potentially not being the final query. Debouncing at 200-300ms means one request fires after the user pauses, the correct query is sent, and the UI doesn't flicker with intermediate results.

Window resize handler — layout recalculation in response to resize is expensive. Recalculating a masonry grid or a responsive chart on every pixel of a window drag fires hundreds of times per second. Debouncing the resize handler means the recalculation fires once after the user releases the window edge, costing one expensive calculation instead of hundreds. The user doesn't perceive the delay because the layout is correct by the time they stop dragging.

Infinite scroll — scroll handlers that trigger data loading should be throttled, not debounced. With debounce, loading would only trigger after the user stops scrolling — too late for a smooth experience. With throttle (or rAF throttle), the position is checked on a regular cadence during scrolling, and a fetch is triggered when the user gets close to the bottom, giving time for data to arrive before they reach the end.

React's useTransition and the debounce overlap — React 18's startTransition marks state updates as non-urgent, letting React defer them. This solves the typing-in-a-search-box problem differently than debounce: the input value updates immediately (urgent), while the filtered results state update is deferred (non-urgent). React's scheduler is smarter than a fixed debounce delay — it defers exactly as long as needed, not a fixed 300ms. Both patterns have their place: useTransition for React render cost, debounce for external API calls.

Game development loops in browser games use a hybrid: requestAnimationFrame for rendering at the display refresh rate, and a fixed timestep for physics/game logic updates. The game loop is neither debounced nor throttled in the traditional sense — it's rate-controlled to maintain consistent simulation regardless of rendering performance. Understanding rAF throttle is foundational to browser game architecture.

Form auto-save — applications like Google Docs, Notion, and Figma auto-save on a debounced interval from the last edit. A 2-second debounce means the save fires 2 seconds after the user stops typing, not on every keystroke. Combined with optimistic UI (showing "Saved" before the server responds) and a leading-edge save on blur (when the user switches tabs), the experience feels both responsive and reliable, all built on the same simple debounce primitive.

Interview Cheat Sheet

  • Debounce: fires after activity STOPS — delay resets on each new trigger
  • Throttle: fires at most ONCE per interval — drops calls within the cooldown window
  • Leading debounce: fires on FIRST call, ignores until quiet — for buttons, form submission
  • Trailing debounce (default): fires after quiet period — for search, resize, auto-save
  • rAF throttle: throttle to display refresh rate — best for visual/DOM updates
  • Trailing throttle: also schedules a final call — guarantees last event is processed
  • debounced.cancel(): clears pending timer — use on component unmount
  • React: useRef for stable debounced function reference across renders
  • useDebounce hook: debounces a value, useEffect cleanup clears timer on change
  • Debounce ≠ throttle: different answers to different questions — choose deliberately
💡

How to Answer in an Interview

  • 1.Implement both debounce and throttle from scratch — this is asked constantly and separates candidates who just know the concept from those who understand the mechanism
  • 2.The elevator analogy for debounce and the turnstile for throttle communicate clearly under pressure
  • 3.Adding .cancel() to your implementation shows production awareness (component unmount, navigation)
  • 4.Choosing between them: "it depends on whether you want the final value or regular updates during activity"
  • 5.The React useDebounce hook with useEffect cleanup is a great React-specific follow-up
📖 Deep Dive Articles
JavaScript Performance Optimization: What Actually Makes Code Fast12 min read

Practice Questions

No questions tagged to this topic yet.

Tag questions in Admin → Questions by setting the "Topic Page" field to javascript-debounce-throttle-interview-questions.

Related Topics

JavaScript Performance Interview Questions
Advanced·6–10 Qs
JavaScript Closure Interview Questions
Intermediate·8–12 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz