Learning Guides
Menu

Throttle & Debounce

12 min readFrontend Patterns & Concepts

Throttle & Debounce

Two of the most essential performance patterns in frontend development. Both control how often a function executes, but they serve different purposes and behave differently.

The Problem They Solve

Consider these common scenarios:

JAVASCRIPT
// Without rate limiting - disaster waiting to happen
window.addEventListener("scroll", () => {
  // This fires 100+ times per second during scroll!
  calculatePosition();
  updateUI();
  fetchData(); // API hammering
});
 
window.addEventListener("resize", () => {
  // Fires continuously during resize
  recalculateLayout();
  rerender();
});
 
searchInput.addEventListener("input", (e) => {
  // Every keystroke = API call
  fetch(`/api/search?q=${e.target.value}`);
});

The consequences:

  • Janky, unresponsive UI
  • Wasted CPU cycles
  • Excessive API calls (rate limiting, costs)
  • Battery drain on mobile devices
  • Memory pressure from queued operations

Debounce: Wait Until Activity Stops

Debounce delays execution until after a period of inactivity. The function only runs once the user stops triggering events.

Mental Model

Think of an elevator door:

  • Someone approaches → door waits
  • Another person approaches → door waits again (timer resets)
  • No one else → door finally closes

Implementation from Scratch

JAVASCRIPT
function debounce(func, delay) {
  let timeoutId;
 
  return function debounced(...args) {
    // Remember the context
    const context = this;
 
    // Clear any existing timer
    clearTimeout(timeoutId);
 
    // Set a new timer
    timeoutId = setTimeout(() => {
      func.apply(context, args);
    }, delay);
  };
}

Enhanced Implementation with Options

JAVASCRIPT
function debounce(func, delay, options = {}) {
  let timeoutId;
  let lastArgs;
  let lastThis;
  let result;
  let lastCallTime;
 
  const { leading = false, trailing = true, maxWait } = options;
 
  function invokeFunc(time) {
    const args = lastArgs;
    const thisArg = lastThis;
 
    lastArgs = lastThis = undefined;
    result = func.apply(thisArg, args);
    return result;
  }
 
  function startTimer(pendingFunc, wait) {
    return setTimeout(pendingFunc, wait);
  }
 
  function cancelTimer() {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
    }
  }
 
  function trailingEdge(time) {
    timeoutId = undefined;
 
    if (trailing && lastArgs) {
      return invokeFunc(time);
    }
    lastArgs = lastThis = undefined;
    return result;
  }
 
  function debounced(...args) {
    const time = Date.now();
    const isInvoking = shouldInvoke(time);
 
    lastArgs = args;
    lastThis = this;
    lastCallTime = time;
 
    if (isInvoking) {
      if (timeoutId === undefined && leading) {
        return invokeFunc(time);
      }
    }
 
    cancelTimer();
    timeoutId = startTimer(() => trailingEdge(Date.now()), delay);
 
    return result;
  }
 
  function shouldInvoke(time) {
    return timeoutId === undefined;
  }
 
  // Cancel pending invocation
  debounced.cancel = function () {
    cancelTimer();
    lastArgs = lastThis = timeoutId = undefined;
  };
 
  // Immediately invoke if pending
  debounced.flush = function () {
    if (timeoutId !== undefined) {
      return trailingEdge(Date.now());
    }
    return result;
  };
 
  // Check if there's a pending invocation
  debounced.pending = function () {
    return timeoutId !== undefined;
  };
 
  return debounced;
}

Real-World Scenarios

1. Search Autocomplete

JAVASCRIPT
const searchInput = document.getElementById("search");
const resultsContainer = document.getElementById("results");
 
async function performSearch(query) {
  if (!query.trim()) {
    resultsContainer.innerHTML = "";
    return;
  }
 
  try {
    resultsContainer.innerHTML = '<div class="loading">Searching...</div>';
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const results = await response.json();
    renderResults(results);
  } catch (error) {
    resultsContainer.innerHTML = '<div class="error">Search failed</div>';
  }
}
 
// Only search 300ms after user stops typing
const debouncedSearch = debounce(performSearch, 300);
 
searchInput.addEventListener("input", (e) => {
  debouncedSearch(e.target.value);
});
 
// Clean up on page unload
window.addEventListener("beforeunload", () => {
  debouncedSearch.cancel();
});

2. Form Auto-Save

JAVASCRIPT
class AutoSaveForm {
  constructor(formElement, saveEndpoint) {
    this.form = formElement;
    this.endpoint = saveEndpoint;
    this.saveIndicator = document.getElementById("save-indicator");
 
    // Debounce save to avoid excessive API calls
    this.debouncedSave = debounce(this.save.bind(this), 1000, {
      maxWait: 5000, // Force save at least every 5 seconds during continuous editing
    });
 
    this.setupListeners();
  }
 
  setupListeners() {
    // Save on any input change
    this.form.addEventListener("input", () => {
      this.showSaving();
      this.debouncedSave();
    });
 
    // Immediate save before leaving
    window.addEventListener("beforeunload", (e) => {
      if (this.debouncedSave.pending()) {
        this.debouncedSave.flush();
        e.preventDefault();
        e.returnValue = "";
      }
    });
  }
 
  showSaving() {
    this.saveIndicator.textContent = "Saving...";
    this.saveIndicator.className = "saving";
  }
 
  async save() {
    const formData = new FormData(this.form);
 
    try {
      await fetch(this.endpoint, {
        method: "POST",
        body: formData,
      });
      this.saveIndicator.textContent = "Saved";
      this.saveIndicator.className = "saved";
    } catch (error) {
      this.saveIndicator.textContent = "Save failed";
      this.saveIndicator.className = "error";
    }
  }
}

3. Window Resize Handler

JAVASCRIPT
function handleResize() {
  const width = window.innerWidth;
  const height = window.innerHeight;
 
  // Expensive layout calculations
  recalculateGrid(width, height);
  repositionElements();
  updateCharts();
}
 
// Only recalculate after resize ends
const debouncedResize = debounce(handleResize, 250);
 
window.addEventListener("resize", debouncedResize);

Throttle: Rate Limit Execution

Throttle ensures a function executes at most once per specified time period. Unlike debounce, it guarantees regular execution during continuous activity.

Mental Model

Think of a rate limiter:

  • You can only post one message per second
  • Additional attempts within that second are ignored
  • After the second passes, you can post again

Implementation from Scratch

JAVASCRIPT
function throttle(func, limit) {
  let inThrottle;
  let lastResult;
 
  return function throttled(...args) {
    const context = this;
 
    if (!inThrottle) {
      lastResult = func.apply(context, args);
      inThrottle = true;
 
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
 
    return lastResult;
  };
}

Enhanced Implementation with Leading/Trailing

JAVASCRIPT
function throttle(func, limit, options = {}) {
  let timeoutId;
  let lastArgs;
  let lastThis;
  let result;
  let lastCallTime = 0;
 
  const { leading = true, trailing = true } = options;
 
  function invokeFunc() {
    const args = lastArgs;
    const thisArg = lastThis;
 
    lastArgs = lastThis = undefined;
    lastCallTime = Date.now();
    result = func.apply(thisArg, args);
    return result;
  }
 
  function remainingWait(time) {
    const timeSinceLastCall = time - lastCallTime;
    return limit - timeSinceLastCall;
  }
 
  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime;
    return lastCallTime === 0 || timeSinceLastCall >= limit;
  }
 
  function trailingEdge() {
    timeoutId = undefined;
 
    if (trailing && lastArgs) {
      return invokeFunc();
    }
    lastArgs = lastThis = undefined;
    return result;
  }
 
  function timerExpired() {
    const time = Date.now();
    if (shouldInvoke(time)) {
      return trailingEdge();
    }
    timeoutId = setTimeout(timerExpired, remainingWait(time));
  }
 
  function throttled(...args) {
    const time = Date.now();
    const isInvoking = shouldInvoke(time);
 
    lastArgs = args;
    lastThis = this;
 
    if (isInvoking) {
      if (timeoutId === undefined) {
        if (leading) {
          return invokeFunc();
        }
        lastCallTime = time;
      }
    }
 
    if (timeoutId === undefined && trailing) {
      timeoutId = setTimeout(timerExpired, remainingWait(time));
    }
 
    return result;
  }
 
  throttled.cancel = function () {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
    }
    lastCallTime = 0;
    lastArgs = lastThis = timeoutId = undefined;
  };
 
  throttled.flush = function () {
    if (timeoutId !== undefined) {
      return trailingEdge();
    }
    return result;
  };
 
  return throttled;
}

Real-World Scenarios

1. Scroll Position Tracking

JAVASCRIPT
class ScrollTracker {
  constructor(options = {}) {
    this.sections = document.querySelectorAll("[data-section]");
    this.navItems = document.querySelectorAll("[data-nav-item]");
    this.onSectionChange = options.onSectionChange || (() => {});
 
    // Throttle to 100ms - smooth but not excessive
    this.throttledUpdate = throttle(this.updateActiveSection.bind(this), 100);
 
    window.addEventListener("scroll", this.throttledUpdate, { passive: true });
  }
 
  updateActiveSection() {
    const scrollPosition = window.scrollY + window.innerHeight / 3;
 
    let currentSection = null;
 
    this.sections.forEach((section) => {
      const top = section.offsetTop;
      const bottom = top + section.offsetHeight;
 
      if (scrollPosition >= top && scrollPosition < bottom) {
        currentSection = section.dataset.section;
      }
    });
 
    if (currentSection) {
      this.highlightNavItem(currentSection);
      this.onSectionChange(currentSection);
    }
  }
 
  highlightNavItem(sectionId) {
    this.navItems.forEach((item) => {
      item.classList.toggle("active", item.dataset.navItem === sectionId);
    });
  }
 
  destroy() {
    window.removeEventListener("scroll", this.throttledUpdate);
    this.throttledUpdate.cancel();
  }
}

2. Mouse Move for Interactive Elements

JAVASCRIPT
class InteractiveBackground {
  constructor(container) {
    this.container = container;
    this.particles = this.createParticles();
 
    // Throttle mouse tracking to 16ms (60fps)
    this.throttledMouseMove = throttle(this.handleMouseMove.bind(this), 16);
 
    container.addEventListener("mousemove", this.throttledMouseMove);
  }
 
  handleMouseMove(event) {
    const rect = this.container.getBoundingClientRect();
    const x = (event.clientX - rect.left) / rect.width;
    const y = (event.clientY - rect.top) / rect.height;
 
    this.updateParticles(x, y);
  }
 
  updateParticles(mouseX, mouseY) {
    this.particles.forEach((particle) => {
      const dx = mouseX - particle.x;
      const dy = mouseY - particle.y;
      const distance = Math.sqrt(dx * dx + dy * dy);
 
      // Repel particles from mouse
      if (distance < 0.2) {
        particle.vx -= dx * 0.05;
        particle.vy -= dy * 0.05;
      }
    });
  }
 
  createParticles() {
    // Create particle array
    return Array.from({ length: 100 }, () => ({
      x: Math.random(),
      y: Math.random(),
      vx: 0,
      vy: 0,
    }));
  }
}

3. API Polling with Rate Limit

JAVASCRIPT
class RateLimitedPoller {
  constructor(endpoint, interval = 5000) {
    this.endpoint = endpoint;
    this.interval = interval;
    this.subscribers = new Set();
    this.isPolling = false;
 
    // Throttle fetch to prevent accidental rapid calls
    this.throttledFetch = throttle(
      this.fetchData.bind(this),
      1000, // Minimum 1 second between fetches
    );
  }
 
  async fetchData() {
    try {
      const response = await fetch(this.endpoint);
      const data = await response.json();
      this.notifySubscribers(data);
      return data;
    } catch (error) {
      this.notifySubscribers(null, error);
      throw error;
    }
  }
 
  subscribe(callback) {
    this.subscribers.add(callback);
    return () => this.subscribers.delete(callback);
  }
 
  notifySubscribers(data, error = null) {
    this.subscribers.forEach((callback) => callback(data, error));
  }
 
  start() {
    if (this.isPolling) return;
 
    this.isPolling = true;
    this.throttledFetch(); // Immediate first fetch
 
    this.pollInterval = setInterval(() => {
      this.throttledFetch();
    }, this.interval);
  }
 
  stop() {
    this.isPolling = false;
    clearInterval(this.pollInterval);
  }
 
  // Manual refresh (throttled)
  refresh() {
    return this.throttledFetch();
  }
}

Debounce vs Throttle: Decision Guide

When to Use Debounce

ScenarioWhy Debounce
Search input autocompleteOnly search after user stops typing
Form validationValidate after user finishes input
Window resizeRecalculate only after resize ends
Auto-save draftsSave after editing pauses
Preventing double-clicksExecute only once after clicking stops

When to Use Throttle

ScenarioWhy Throttle
Scroll position trackingNeed regular updates during scroll
Mouse movement effectsSmooth animation at controlled rate
Game loop inputsConsistent input processing
Analytics eventsRegular but controlled event firing
Infinite scroll loadingTrigger at intervals while scrolling

Visual Comparison

PLAINTEXT
User Events:    ●-●●--●-●●●●--●-●--●●●●●--●
                |__|__|__|__|__|__|__|__|__|
 
Debounce:       ----------------------●-----●
(300ms delay)   Waits for silence, then fires
 
Throttle:       ●-----●-----●-----●-----●---
(300ms limit)   Fires at regular intervals

Advanced Pattern: Debounced Throttle

Sometimes you need both: regular updates during activity, but also a final update after activity stops.

JAVASCRIPT
function throttleDebounce(func, throttleLimit, debounceDelay) {
  const throttled = throttle(func, throttleLimit);
  const debounced = debounce(func, debounceDelay);
 
  return function (...args) {
    throttled.apply(this, args); // Regular updates
    debounced.apply(this, args); // Final update after stop
  };
}
 
// Usage: Update every 100ms during scroll, plus final update
const handleScroll = throttleDebounce(
  updateScrollPosition,
  100, // Throttle: every 100ms
  150, // Debounce: 150ms after stop
);

React Hooks

useDebounce Hook

JAVASCRIPT
import { useState, useEffect, useRef, useCallback } from "react";
 
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
 
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
 
    return () => clearTimeout(handler);
  }, [value, delay]);
 
  return debouncedValue;
}
 
function useDebouncedCallback(callback, delay, deps = []) {
  const callbackRef = useRef(callback);
  const timeoutRef = useRef();
 
  // Update callback ref when callback changes
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
 
  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);
 
  return useCallback(
    (...args) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
 
      timeoutRef.current = setTimeout(() => {
        callbackRef.current(...args);
      }, delay);
    },
    [delay, ...deps],
  );
}
 
// Usage
function SearchComponent() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
 
  // Debounce the search query
  const debouncedQuery = useDebounce(query, 300);
 
  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery).then(setResults);
    }
  }, [debouncedQuery]);
 
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

useThrottle Hook

JAVASCRIPT
import { useState, useEffect, useRef, useCallback } from "react";
 
function useThrottle(value, limit) {
  const [throttledValue, setThrottledValue] = useState(value);
  const lastRan = useRef(Date.now());
 
  useEffect(() => {
    const handler = setTimeout(
      () => {
        if (Date.now() - lastRan.current >= limit) {
          setThrottledValue(value);
          lastRan.current = Date.now();
        }
      },
      limit - (Date.now() - lastRan.current),
    );
 
    return () => clearTimeout(handler);
  }, [value, limit]);
 
  return throttledValue;
}
 
function useThrottledCallback(callback, limit, deps = []) {
  const lastRan = useRef(0);
  const timeoutRef = useRef();
  const callbackRef = useRef(callback);
 
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
 
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);
 
  return useCallback(
    (...args) => {
      const now = Date.now();
      const remaining = limit - (now - lastRan.current);
 
      if (remaining <= 0) {
        lastRan.current = now;
        callbackRef.current(...args);
      } else if (!timeoutRef.current) {
        timeoutRef.current = setTimeout(() => {
          lastRan.current = Date.now();
          timeoutRef.current = null;
          callbackRef.current(...args);
        }, remaining);
      }
    },
    [limit, ...deps],
  );
}
 
// Usage
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
 
  const handleScroll = useThrottledCallback(() => {
    setScrollY(window.scrollY);
  }, 100);
 
  useEffect(() => {
    window.addEventListener("scroll", handleScroll, { passive: true });
    return () => window.removeEventListener("scroll", handleScroll);
  }, [handleScroll]);
 
  return <div>Scroll position: {scrollY}</div>;
}

Performance Considerations

Memory and Cleanup

JAVASCRIPT
class Component {
  constructor() {
    // Store references for cleanup
    this.debouncedHandler = debounce(this.handleEvent.bind(this), 300);
    this.throttledHandler = throttle(this.handleOther.bind(this), 100);
 
    window.addEventListener("resize", this.debouncedHandler);
    window.addEventListener("scroll", this.throttledHandler);
  }
 
  destroy() {
    // IMPORTANT: Cancel pending executions
    this.debouncedHandler.cancel();
    this.throttledHandler.cancel();
 
    // Remove listeners
    window.removeEventListener("resize", this.debouncedHandler);
    window.removeEventListener("scroll", this.throttledHandler);
  }
}

requestAnimationFrame Alternative

For visual updates, consider requestAnimationFrame instead of throttle:

JAVASCRIPT
function rafThrottle(callback) {
  let requestId = null;
  let lastArgs = null;
 
  const later = () => {
    requestId = null;
    callback(...lastArgs);
  };
 
  const throttled = (...args) => {
    lastArgs = args;
    if (requestId === null) {
      requestId = requestAnimationFrame(later);
    }
  };
 
  throttled.cancel = () => {
    if (requestId !== null) {
      cancelAnimationFrame(requestId);
      requestId = null;
    }
  };
 
  return throttled;
}
 
// Perfectly synced with browser refresh rate
const updateAnimation = rafThrottle((x, y) => {
  element.style.transform = `translate(${x}px, ${y}px)`;
});

Common Mistakes to Avoid

1. Creating New Functions in Render

JAVASCRIPT
// ❌ BAD: Creates new debounced function every render
function SearchBad() {
  const [query, setQuery] = useState("");
 
  return <input onChange={debounce((e) => setQuery(e.target.value), 300)} />;
}
 
// ✅ GOOD: Stable reference
function SearchGood() {
  const [query, setQuery] = useState("");
 
  const debouncedSetQuery = useMemo(
    () => debounce((value) => setQuery(value), 300),
    [],
  );
 
  useEffect(() => {
    return () => debouncedSetQuery.cancel();
  }, [debouncedSetQuery]);
 
  return <input onChange={(e) => debouncedSetQuery(e.target.value)} />;
}

2. Not Cleaning Up

JAVASCRIPT
// ❌ BAD: Memory leak
useEffect(() => {
  const handler = debounce(handleResize, 300);
  window.addEventListener("resize", handler);
}, []);
 
// ✅ GOOD: Proper cleanup
useEffect(() => {
  const handler = debounce(handleResize, 300);
  window.addEventListener("resize", handler);
 
  return () => {
    handler.cancel(); // Cancel pending
    window.removeEventListener("resize", handler);
  };
}, []);

Summary

AspectDebounceThrottle
ExecutionAfter activity stopsAt regular intervals
Use CaseFinal state mattersProgress matters
ExamplesSearch, resize, saveScroll, mouse, games
DelayFrom last eventFrom last execution

Both patterns are essential tools for building performant frontends. Choose based on whether you need to respond during continuous activity (throttle) or only after it stops (debounce).