PushBackLog

Debounce & Throttle

Advisory enforcement Complete by PushBackLog team
Topic: performance Topic: user-interface Skillset: frontend Skillset: fullstack Technology: JavaScript Technology: TypeScript Stage: execution Stage: review

Debounce & Throttle

Status: Complete
Category: Performance
Default enforcement: Advisory
Author: PushBackLog team


Tags

  • Topic: performance, user-interface
  • Skillset: frontend, fullstack
  • Technology: JavaScript, TypeScript
  • Stage: execution, review

Summary

Debouncing and throttling are techniques for controlling how often a function is called in response to high-frequency events such as keystrokes, scroll events, mouse movements, and resize events. Without them, fast-typing users trigger dozens of redundant API calls per second, scroll handlers block the render thread, and resize listeners hammer layout recalculations. The two techniques differ in intent: debounce collapses bursts into a single call after a quiet period; throttle guarantees a call runs at most once per interval.


Rationale

High-frequency events cause performance problems

Browser events like input, scroll, mousemove, and resize can fire hundreds of times per second. A search input that fires an API request on every keystroke will send dozens of requests for a five-character search term. Most of those requests are wasted: the intermediate states are never needed, and the out-of-order responses create race conditions. The same principle applies on the server side: a rate-limited API, a webhook handler, or a timer task that runs too frequently wastes compute and can cause cascading failures.

Debounce vs. throttle trade-offs

Neither technique is universally better. The right choice depends on whether you care more about the latest event (debounce) or about regular intermediate updates (throttle). Applying the wrong pattern produces UIs that feel broken: debouncing a scroll progress bar means it only updates when the user stops scrolling; throttling a form submit button may allow multiple submissions.


Guidance

Debounce

Debouncing delays execution until a specified time has elapsed without the event firing again. The timer resets on each event.

// Custom implementation
function debounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delayMs: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout> | null = null;

  return (...args: Parameters<T>): void => {
    if (timer !== null) {
      clearTimeout(timer);
    }
    timer = setTimeout(() => fn(...args), delayMs);
  };
}

// Search input — fire after user stops typing for 300ms
const handleSearch = debounce(async (query: string) => {
  const results = await searchApi(query);
  setResults(results);
}, 300);

inputElement.addEventListener('input', (e) => {
  handleSearch((e.target as HTMLInputElement).value);
});

When to debounce:

  • Search / autocomplete inputs
  • Form auto-save (“saving…” after user stops typing)
  • Window resize recalculations
  • Validation that triggers API calls

Throttle

Throttling ensures the function fires at most once per interval, dropping intermediate events.

// Custom implementation
function throttle<T extends (...args: unknown[]) => void>(
  fn: T,
  intervalMs: number
): (...args: Parameters<T>) => void {
  let lastRun = 0;

  return (...args: Parameters<T>): void => {
    const now = Date.now();
    if (now - lastRun >= intervalMs) {
      lastRun = now;
      fn(...args);
    }
  };
}

// Scroll progress bar — update at most 10 times per second
const updateScrollProgress = throttle(() => {
  const scrolled = window.scrollY;
  const total = document.body.scrollHeight - window.innerHeight;
  progressBar.style.width = `${(scrolled / total) * 100}%`;
}, 100);

window.addEventListener('scroll', updateScrollProgress);

When to throttle:

  • Scroll-based animations or progress indicators
  • Mouse move tracking
  • Button click protection (prevent double-submit)
  • Rate-limited API polling
  • Server-side event processing with a busy external trigger

Leading vs. trailing edge

Both debounce and throttle can fire on the leading edge (first call), trailing edge (last call), or both:

EdgeBehaviourBest for
Trailing (default)Fires after quiet/interval expiresSearch, auto-save
LeadingFires immediately, then suppresses until interval passesButton protection, first-click feedback
BothFires immediately and at end of burstInfrequent, important events
// Using Lodash for built-in leading/trailing options
import { debounce, throttle } from 'lodash';

// Leading edge: fire immediately, then suppress for 500ms
const handleSubmit = debounce(submitForm, 500, { leading: true, trailing: false });

// Both edges: fire immediately and again after burst settles
const handleResize = debounce(recalculate, 200, { leading: true, trailing: true });

Using lodash vs. custom

  • Custom implementation: appropriate when you have a simple use case and want no dependency
  • Lodash _.debounce / _.throttle: battle-tested, supports leading/trailing/cancel/flush — prefer in most production code
import debounce from 'lodash/debounce';

// Always cancel on component unmount to prevent memory leaks
const debouncedSearch = debounce(fetchSuggestions, 300);

useEffect(() => {
  return () => debouncedSearch.cancel(); // Clean up on unmount
}, []);

Common mistakes

MistakeProblemFix
Creating debounce/throttle inside renderNew function created every render, breaking the debounce stateCreate once with useRef or useMemo
Not cancelling on unmountMemory leaks, state updates on unmounted componentsAlways call .cancel() in cleanup
Debouncing button click for UX feedbackUser sees no immediate responseUse leading edge or show loading state on first click
Throttle interval too longUI feels laggyStart at 16ms (one animation frame) for visual updates

React hook pattern

function useDebounce<T>(value: T, delayMs: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debouncedValue;
}

// Usage
const debouncedQuery = useDebounce(searchInput, 300);

useEffect(() => {
  if (debouncedQuery) {
    fetchResults(debouncedQuery);
  }
}, [debouncedQuery]);