Back to blog

How to Implement Debouncing and Throttling in Vanilla JavaScript and React

Web applications respond to user actions via event listeners. However, certain events (such as resizing window ports, scrolling pages, dragging elements, or typing into search bars) can fire dozens of times per second.

If these events trigger heavy DOM updates, layout calculations, or API network requests, they can block the main browser thread, resulting in laggy scrolling and high server loads.

To solve this, developers use two performance patterns: Debouncing and Throttling.

In this guide, we will analyze the differences between these two patterns, write custom implementations in vanilla JavaScript, and explore how to use them safely inside React hooks.

Debounce vs. Throttle: The Core Difference

Both techniques control how often a function executes over time, but they use different throttling strategies:

  • Debounce delays the execution of a function until a specified idle time has passed since the last event trigger. If another event occurs before the idle window closes, the timer resets. It groups multiple rapid events into a single execution.
  • Throttle guarantees that a function runs at most once in a specified time window. It limits the execution speed of a continuous action stream.

Implementing Debounce in Vanilla JavaScript

Debouncing is ideal for search boxes. You want to wait until the user stops typing before making an API call, avoiding redundant network queries.

Here is the vanilla JavaScript implementation using closures and timers:

function debounce<T extends (...args: any[]) => void>(func: T, delay: number): (...args: Parameters<T>) => void {
  let timeoutId: ReturnType<typeof setTimeout> | null = null;

  return function (...args: Parameters<T>) {
    // Clear the active timer
    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    // Set a new timer
    timeoutId = setTimeout(() => {
      func(...args);
    }, delay);
  };
}

Implementing Throttle in Vanilla JavaScript

Throttling is ideal for scroll listeners. If you want to check if a user has scrolled near the bottom of a page to trigger infinite loading, you do not need to check on every pixel. Checking once every 200ms is sufficient.

Here is a timestamp-based implementation of throttling:

function throttle<T extends (...args: any[]) => void>(func: T, limit: number): (...args: Parameters<T>) => void {
  let lastFunc: ReturnType<typeof setTimeout> | null = null;
  let lastRan: number = 0;

  return function (...args: Parameters<T>) {
    const now = Date.now();

    if (!lastRan) {
      func(...args);
      lastRan = now;
    } else {
      if (lastFunc) {
        clearTimeout(lastFunc);
      }

      lastFunc = setTimeout(() => {
        if (Date.now() - lastRan >= limit) {
          func(...args);
          lastRan = Date.now();
        }
      }, limit - (now - lastRan));
    }
  };
}

Implementing Debounce inside React

Using debounce inside React is tricky. If you wrap a function in debounce inside a standard React component, the debounced function is recreated on every render pass, invalidating the internal timer closures.

To fix this, you must store the debounced function inside a useRef reference, ensuring it persists across renders.

Here is a custom useDebounce hook in React:

import { useEffect, useRef } from 'react';

export function useDebounceCallback<T extends (...args: any[]) => void>(callback: T, delay: number) {
  const callbackRef = useRef(callback);
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // Keep callback reference updated
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // Clean up timer on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  return function (...args: Parameters<T>) {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    timeoutRef.current = setTimeout(() => {
      callbackRef.current(...args);
    }, delay);
  };
}

Usage Example: Search Component

export function SearchInput() {
  const handleSearch = useDebounceCallback((query: string) => {
    console.log('API Request sent for query:', query);
    // Execute fetch here...
  }, 500);

  return (
    <input
      type="text"
      placeholder="Type to search..."
      onChange={(e) => handleSearch(e.target.value)}
      class="border p-2 rounded"
    />
  );
}

Conclusion

Both debouncing and throttling are essential performance utilities. Use debouncing when you want to group multiple triggers into a single final execution (like autocomplete search inputs). Use throttling when you want to regulate the execution rate of continuous streams of events (like scrolling, resizing, or click actions).