Performance & OptimizationFeatured

JavaScript Debouncing and Throttling: Rate-Limiting Techniques

Master debouncing and throttling in JavaScript. Learn how to optimize performance by controlling function execution rates in response to frequent events.

By JavaScriptDoc Team
debouncingthrottlingperformanceevent handlingoptimization

JavaScript Debouncing and Throttling: Rate-Limiting Techniques

Debouncing and throttling are two essential techniques for controlling how often a function executes in response to events. They help optimize performance, reduce API calls, and improve user experience by preventing functions from being called too frequently.

Understanding the Problem

When dealing with events that fire rapidly (like scrolling, resizing, or typing), executing expensive operations for each event can cause performance issues.

// Problem: This fires hundreds of times per second while scrolling
window.addEventListener('scroll', () => {
  console.log('Scroll event fired!');
  // Expensive operation like DOM manipulation or API call
  calculateScrollPosition();
  updateUIElements();
  saveScrollPosition();
});

// Problem: This fires on every keystroke
searchInput.addEventListener('input', (e) => {
  console.log('Searching for:', e.target.value);
  // API call on every keystroke
  fetch(`/api/search?q=${e.target.value}`)
    .then((res) => res.json())
    .then(displayResults);
});

What is Debouncing?

Debouncing ensures that a function is only executed after a certain period of inactivity. It delays the execution until after the events have stopped firing for a specified duration.

// Basic debounce implementation
function debounce(func, delay) {
  let timeoutId;

  return function (...args) {
    // Clear previous timeout if it exists
    clearTimeout(timeoutId);

    // Set new timeout
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// Example usage
const debouncedSearch = debounce((query) => {
  console.log('Searching for:', query);
  // Perform search
}, 300);

// Only executes 300ms after user stops typing
searchInput.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

Advanced Debounce Implementations

// Debounce with immediate option
function debounce(func, delay, immediate = false) {
  let timeoutId;

  return function (...args) {
    const callNow = immediate && !timeoutId;

    clearTimeout(timeoutId);

    timeoutId = setTimeout(() => {
      timeoutId = null;
      if (!immediate) {
        func.apply(this, args);
      }
    }, delay);

    if (callNow) {
      func.apply(this, args);
    }
  };
}

// Debounce with cancel method
function debounceWithCancel(func, delay) {
  let timeoutId;

  function debounced(...args) {
    clearTimeout(timeoutId);

    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  }

  debounced.cancel = function () {
    clearTimeout(timeoutId);
    timeoutId = null;
  };

  return debounced;
}

// Usage with cancel
const debouncedSave = debounceWithCancel(saveData, 1000);
debouncedSave(data);
// Cancel if needed
debouncedSave.cancel();

// Debounce with return value using promises
function debouncePromise(func, delay) {
  let timeoutId;
  let resolveList = [];

  return function (...args) {
    return new Promise((resolve) => {
      clearTimeout(timeoutId);

      resolveList.push(resolve);

      timeoutId = setTimeout(() => {
        const result = func.apply(this, args);

        // Resolve all pending promises
        resolveList.forEach((res) => res(result));
        resolveList = [];
      }, delay);
    });
  };
}

// Async debounce usage
const debouncedFetch = debouncePromise(async (query) => {
  const response = await fetch(`/api/search?q=${query}`);
  return response.json();
}, 300);

// All calls get the same result
const result = await debouncedFetch('javascript');

What is Throttling?

Throttling ensures that a function is executed at most once in a specified time period. Unlike debouncing, it guarantees regular execution at intervals.

// Basic throttle implementation
function throttle(func, limit) {
  let inThrottle;

  return function (...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;

      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

// Example usage
const throttledScroll = throttle(() => {
  console.log('Scroll position:', window.scrollY);
  updateScrollIndicator();
}, 100);

// Executes at most once every 100ms
window.addEventListener('scroll', throttledScroll);

Advanced Throttle Implementations

// Throttle with trailing call
function throttle(func, limit, options = {}) {
  let inThrottle;
  let lastArgs;
  let lastThis;

  const { leading = true, trailing = true } = options;

  return function (...args) {
    lastArgs = args;
    lastThis = this;

    if (!inThrottle) {
      if (leading) {
        func.apply(this, args);
      }

      inThrottle = true;

      setTimeout(() => {
        inThrottle = false;

        if (trailing && lastArgs) {
          func.apply(lastThis, lastArgs);
          lastArgs = null;
          lastThis = null;
        }
      }, limit);
    }
  };
}

// Throttle with timestamp
function throttleWithTimestamp(func, limit) {
  let lastCall = 0;

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

    if (now - lastCall >= limit) {
      lastCall = now;
      return func.apply(this, args);
    }
  };
}

// Request Animation Frame throttle
function rafThrottle(func) {
  let rafId;
  let lastArgs;

  return function (...args) {
    lastArgs = args;

    if (!rafId) {
      rafId = requestAnimationFrame(() => {
        func.apply(this, lastArgs);
        rafId = null;
      });
    }
  };
}

// Usage for smooth animations
const handleScroll = rafThrottle(() => {
  const scrollPercent =
    (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
  progressBar.style.width = `${scrollPercent}%`;
});

window.addEventListener('scroll', handleScroll);

Debounce vs Throttle Comparison

// Visual comparison
let debounceCount = 0;
let throttleCount = 0;

const debouncedIncrement = debounce(() => {
  debounceCount++;
  console.log('Debounce count:', debounceCount);
}, 1000);

const throttledIncrement = throttle(() => {
  throttleCount++;
  console.log('Throttle count:', throttleCount);
}, 1000);

// Rapid fire events
for (let i = 0; i < 10; i++) {
  setTimeout(() => {
    debouncedIncrement(); // Executes once after all calls stop
    throttledIncrement(); // Executes immediately, then max once per second
  }, i * 100);
}

// After 2 seconds:
// Debounce count: 1 (only final call)
// Throttle count: 2 (first call + one during the period)

Practical Use Cases

1. Search Input with Debouncing

// Search autocomplete
class SearchAutocomplete {
  constructor(inputElement, options = {}) {
    this.input = inputElement;
    this.options = {
      minChars: 2,
      delay: 300,
      ...options,
    };

    this.cache = new Map();
    this.init();
  }

  init() {
    this.debouncedSearch = debounce(
      this.performSearch.bind(this),
      this.options.delay
    );

    this.input.addEventListener('input', this.handleInput.bind(this));
  }

  handleInput(event) {
    const query = event.target.value.trim();

    if (query.length < this.options.minChars) {
      this.hideResults();
      return;
    }

    // Check cache first
    if (this.cache.has(query)) {
      this.displayResults(this.cache.get(query));
      return;
    }

    // Show loading state
    this.showLoading();

    // Perform debounced search
    this.debouncedSearch(query);
  }

  async performSearch(query) {
    try {
      const response = await fetch(
        `/api/search?q=${encodeURIComponent(query)}`
      );
      const results = await response.json();

      // Cache results
      this.cache.set(query, results);

      // Limit cache size
      if (this.cache.size > 50) {
        const firstKey = this.cache.keys().next().value;
        this.cache.delete(firstKey);
      }

      this.displayResults(results);
    } catch (error) {
      this.showError('Search failed');
    }
  }

  displayResults(results) {
    // Display logic
    console.log('Displaying results:', results);
  }

  showLoading() {
    // Show loading indicator
  }

  hideResults() {
    // Hide results dropdown
  }

  showError(message) {
    // Show error message
  }
}

// Usage
const searchBox = new SearchAutocomplete(document.getElementById('search'), {
  delay: 400,
});

2. Window Resize with Throttling

// Responsive layout manager
class ResponsiveManager {
  constructor() {
    this.breakpoints = {
      mobile: 768,
      tablet: 1024,
      desktop: 1440,
    };

    this.currentBreakpoint = this.getBreakpoint();
    this.callbacks = new Map();

    this.init();
  }

  init() {
    // Throttle resize handler
    this.throttledResize = throttle(this.handleResize.bind(this), 200, {
      trailing: true,
    });

    window.addEventListener('resize', this.throttledResize);

    // Initial setup
    this.handleResize();
  }

  getBreakpoint() {
    const width = window.innerWidth;

    if (width < this.breakpoints.mobile) return 'mobile';
    if (width < this.breakpoints.tablet) return 'tablet';
    if (width < this.breakpoints.desktop) return 'desktop';
    return 'wide';
  }

  handleResize() {
    const newBreakpoint = this.getBreakpoint();

    // Only trigger callbacks if breakpoint changed
    if (newBreakpoint !== this.currentBreakpoint) {
      const oldBreakpoint = this.currentBreakpoint;
      this.currentBreakpoint = newBreakpoint;

      // Trigger breakpoint change callbacks
      this.callbacks.forEach((callback) => {
        callback(newBreakpoint, oldBreakpoint);
      });
    }

    // Always trigger resize callbacks
    this.updateDimensions();
  }

  updateDimensions() {
    // Update any dimension-dependent elements
    const vh = window.innerHeight * 0.01;
    document.documentElement.style.setProperty('--vh', `${vh}px`);
  }

  onBreakpointChange(callback) {
    const id = Symbol();
    this.callbacks.set(id, callback);

    // Return unsubscribe function
    return () => this.callbacks.delete(id);
  }

  destroy() {
    window.removeEventListener('resize', this.throttledResize);
    this.callbacks.clear();
  }
}

// Usage
const responsive = new ResponsiveManager();

responsive.onBreakpointChange((newBreakpoint, oldBreakpoint) => {
  console.log(`Breakpoint changed: ${oldBreakpoint} -> ${newBreakpoint}`);

  // Adjust layout
  if (newBreakpoint === 'mobile') {
    document.body.classList.add('mobile-layout');
  } else {
    document.body.classList.remove('mobile-layout');
  }
});

3. Scroll Events with Both Techniques

// Advanced scroll handler
class ScrollManager {
  constructor(options = {}) {
    this.options = {
      throttleDelay: 100,
      debounceDelay: 150,
      ...options,
    };

    this.isScrolling = false;
    this.scrollDirection = null;
    this.lastScrollY = window.scrollY;

    this.init();
  }

  init() {
    // Throttle for continuous updates
    this.throttledScroll = throttle(
      this.onScroll.bind(this),
      this.options.throttleDelay
    );

    // Debounce for scroll end detection
    this.debouncedScrollEnd = debounce(
      this.onScrollEnd.bind(this),
      this.options.debounceDelay
    );

    window.addEventListener('scroll', this.handleScroll.bind(this), {
      passive: true,
    });
  }

  handleScroll() {
    if (!this.isScrolling) {
      this.onScrollStart();
    }

    this.throttledScroll();
    this.debouncedScrollEnd();
  }

  onScrollStart() {
    this.isScrolling = true;
    document.body.classList.add('is-scrolling');
    console.log('Scroll started');
  }

  onScroll() {
    const currentScrollY = window.scrollY;

    // Determine scroll direction
    if (currentScrollY > this.lastScrollY) {
      this.scrollDirection = 'down';
    } else if (currentScrollY < this.lastScrollY) {
      this.scrollDirection = 'up';
    }

    // Update UI elements
    this.updateScrollIndicators();

    // Parallax effects
    this.updateParallax();

    // Lazy loading
    this.checkLazyImages();

    this.lastScrollY = currentScrollY;
  }

  onScrollEnd() {
    this.isScrolling = false;
    document.body.classList.remove('is-scrolling');
    console.log('Scroll ended');

    // Save scroll position
    this.saveScrollPosition();
  }

  updateScrollIndicators() {
    const scrollPercent =
      (window.scrollY /
        (document.documentElement.scrollHeight - window.innerHeight)) *
      100;

    // Update progress bar
    const progressBar = document.querySelector('.scroll-progress');
    if (progressBar) {
      progressBar.style.width = `${scrollPercent}%`;
    }

    // Show/hide back to top button
    const backToTop = document.querySelector('.back-to-top');
    if (backToTop) {
      backToTop.classList.toggle('visible', window.scrollY > 500);
    }
  }

  updateParallax() {
    const parallaxElements = document.querySelectorAll('[data-parallax]');

    parallaxElements.forEach((element) => {
      const speed = parseFloat(element.dataset.parallax) || 0.5;
      const yPos = -(window.scrollY * speed);
      element.style.transform = `translateY(${yPos}px)`;
    });
  }

  checkLazyImages() {
    const lazyImages = document.querySelectorAll('img[data-lazy]');

    lazyImages.forEach((img) => {
      if (this.isInViewport(img)) {
        img.src = img.dataset.lazy;
        img.removeAttribute('data-lazy');
      }
    });
  }

  isInViewport(element) {
    const rect = element.getBoundingClientRect();
    return (
      rect.bottom >= 0 &&
      rect.right >= 0 &&
      rect.top <= window.innerHeight &&
      rect.left <= window.innerWidth
    );
  }

  saveScrollPosition() {
    localStorage.setItem('scrollPosition', window.scrollY);
  }

  destroy() {
    window.removeEventListener('scroll', this.handleScroll);
  }
}

// Usage
const scrollManager = new ScrollManager({
  throttleDelay: 50,
  debounceDelay: 200,
});

4. Form Validation

// Form validator with debouncing
class FormValidator {
  constructor(form, rules) {
    this.form = form;
    this.rules = rules;
    this.errors = {};

    this.init();
  }

  init() {
    // Create debounced validators for each field
    this.validators = {};

    Object.keys(this.rules).forEach((fieldName) => {
      const field = this.form.querySelector(`[name="${fieldName}"]`);

      if (field) {
        // Debounce validation to avoid too frequent checks
        this.validators[fieldName] = debounce(
          () => this.validateField(fieldName),
          500
        );

        // Add event listeners
        field.addEventListener('input', () => {
          this.clearError(fieldName);
          this.validators[fieldName]();
        });

        field.addEventListener('blur', () => {
          // Immediate validation on blur
          this.validateField(fieldName);
        });
      }
    });

    // Handle form submission
    this.form.addEventListener('submit', this.handleSubmit.bind(this));
  }

  validateField(fieldName) {
    const field = this.form.querySelector(`[name="${fieldName}"]`);
    const rules = this.rules[fieldName];
    const value = field.value;

    for (const rule of rules) {
      const error = rule.validate(value, field);

      if (error) {
        this.setError(fieldName, error);
        return false;
      }
    }

    this.clearError(fieldName);
    return true;
  }

  setError(fieldName, message) {
    this.errors[fieldName] = message;

    const field = this.form.querySelector(`[name="${fieldName}"]`);
    const errorElement = this.form.querySelector(
      `[data-error-for="${fieldName}"]`
    );

    if (field) {
      field.classList.add('error');
    }

    if (errorElement) {
      errorElement.textContent = message;
      errorElement.style.display = 'block';
    }
  }

  clearError(fieldName) {
    delete this.errors[fieldName];

    const field = this.form.querySelector(`[name="${fieldName}"]`);
    const errorElement = this.form.querySelector(
      `[data-error-for="${fieldName}"]`
    );

    if (field) {
      field.classList.remove('error');
    }

    if (errorElement) {
      errorElement.style.display = 'none';
    }
  }

  async handleSubmit(event) {
    event.preventDefault();

    // Validate all fields
    let isValid = true;

    for (const fieldName of Object.keys(this.rules)) {
      if (!this.validateField(fieldName)) {
        isValid = false;
      }
    }

    if (isValid) {
      // Submit form
      console.log('Form is valid, submitting...');
      this.submitForm();
    } else {
      console.log('Form has errors:', this.errors);
    }
  }

  submitForm() {
    // Form submission logic
    const formData = new FormData(this.form);
    // Send to server...
  }
}

// Validation rules
const validationRules = {
  email: [
    {
      validate: (value) => (!value ? 'Email is required' : null),
    },
    {
      validate: (value) => {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return !emailRegex.test(value) ? 'Invalid email format' : null;
      },
    },
  ],
  password: [
    {
      validate: (value) => (!value ? 'Password is required' : null),
    },
    {
      validate: (value) =>
        value.length < 8 ? 'Password must be at least 8 characters' : null,
    },
  ],
  username: [
    {
      validate: async (value) => {
        if (!value) return 'Username is required';

        // Simulate API check
        const response = await fetch(`/api/check-username?username=${value}`);
        const data = await response.json();

        return data.exists ? 'Username already taken' : null;
      },
    },
  ],
};

// Usage
const form = document.getElementById('signup-form');
const validator = new FormValidator(form, validationRules);

Advanced Patterns

1. Combining Debounce and Throttle

// Hybrid approach for optimal performance
class HybridRateLimiter {
  constructor(options = {}) {
    this.options = {
      throttleDelay: 100,
      debounceDelay: 300,
      maxWait: 1000,
      ...options,
    };

    this.lastThrottleTime = 0;
    this.debounceTimer = null;
    this.pendingArgs = null;
  }

  execute(func) {
    return (...args) => {
      const now = Date.now();
      const timeSinceLastThrottle = now - this.lastThrottleTime;

      // Clear existing debounce
      clearTimeout(this.debounceTimer);

      // Throttle logic
      if (timeSinceLastThrottle >= this.options.throttleDelay) {
        this.lastThrottleTime = now;
        func.apply(this, args);
      } else {
        // Store args for debounced call
        this.pendingArgs = args;

        // Debounce logic
        this.debounceTimer = setTimeout(() => {
          if (this.pendingArgs) {
            func.apply(this, this.pendingArgs);
            this.pendingArgs = null;
            this.lastThrottleTime = Date.now();
          }
        }, this.options.debounceDelay);
      }

      // Max wait time guarantee
      if (timeSinceLastThrottle >= this.options.maxWait && this.pendingArgs) {
        func.apply(this, this.pendingArgs);
        this.pendingArgs = null;
        this.lastThrottleTime = now;
      }
    };
  }
}

// Usage: Best of both worlds
const hybridHandler = new HybridRateLimiter({
  throttleDelay: 100,
  debounceDelay: 300,
  maxWait: 1000,
});

const optimizedHandler = hybridHandler.execute((data) => {
  console.log('Processing:', data);
});

2. Class-based Implementation

// Reusable rate limiter classes
class RateLimiter {
  constructor(func, options = {}) {
    this.func = func;
    this.options = options;
  }

  cancel() {
    // To be implemented by subclasses
  }
}

class Debouncer extends RateLimiter {
  constructor(func, delay, options = {}) {
    super(func, options);
    this.delay = delay;
    this.timerId = null;
  }

  execute(...args) {
    return new Promise((resolve) => {
      this.cancel();

      this.timerId = setTimeout(() => {
        const result = this.func.apply(this, args);
        resolve(result);
      }, this.delay);
    });
  }

  cancel() {
    if (this.timerId) {
      clearTimeout(this.timerId);
      this.timerId = null;
    }
  }
}

class Throttler extends RateLimiter {
  constructor(func, limit, options = {}) {
    super(func, options);
    this.limit = limit;
    this.inThrottle = false;
    this.lastArgs = null;
  }

  execute(...args) {
    this.lastArgs = args;

    if (!this.inThrottle) {
      this.inThrottle = true;

      const result = this.func.apply(this, args);

      setTimeout(() => {
        this.inThrottle = false;

        if (this.options.trailing && this.lastArgs) {
          this.func.apply(this, this.lastArgs);
        }
      }, this.limit);

      return result;
    }
  }

  cancel() {
    this.inThrottle = false;
    this.lastArgs = null;
  }
}

// Usage
const searchDebouncer = new Debouncer(performSearch, 300);
const scrollThrottler = new Throttler(updateUI, 100, { trailing: true });

3. React Hooks

// Custom React hooks for debounce and throttle
import { useState, useEffect, useRef, useCallback } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

function useThrottle(value, limit) {
  const [throttledValue, setThrottledValue] = useState(value);
  const lastRun = useRef(Date.now());

  useEffect(() => {
    const handler = setTimeout(
      () => {
        if (Date.now() - lastRun.current >= limit) {
          setThrottledValue(value);
          lastRun.current = Date.now();
        }
      },
      limit - (Date.now() - lastRun.current)
    );

    return () => clearTimeout(handler);
  }, [value, limit]);

  return throttledValue;
}

function useDebouncedCallback(callback, delay) {
  const inputsRef = useRef(callback);
  const timerRef = useRef();

  useEffect(() => {
    inputsRef.current = callback;
  }, [callback]);

  return useCallback(
    (...args) => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }

      timerRef.current = setTimeout(() => {
        inputsRef.current(...args);
      }, delay);
    },
    [delay]
  );
}

// Usage in React component
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  const [scrollY, setScrollY] = useState(0);
  const throttledScrollY = useThrottle(scrollY, 100);

  // Effect for debounced search
  useEffect(() => {
    if (debouncedSearchTerm) {
      performSearch(debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);

  // Effect for throttled scroll
  useEffect(() => {
    updateScrollUI(throttledScrollY);
  }, [throttledScrollY]);

  // Debounced callback example
  const debouncedSave = useDebouncedCallback((data) => {
    saveToServer(data);
  }, 1000);

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
    </div>
  );
}

Performance Testing

// Performance comparison utility
class PerformanceMonitor {
  static compare(scenarios) {
    const results = {};

    scenarios.forEach(({ name, func, events, duration }) => {
      console.log(`Testing ${name}...`);

      let callCount = 0;
      const wrappedFunc = (...args) => {
        callCount++;
        return func(...args);
      };

      const startTime = performance.now();

      // Simulate events
      const interval = setInterval(() => {
        wrappedFunc(Math.random());
      }, events.interval);

      setTimeout(() => {
        clearInterval(interval);
        const endTime = performance.now();

        results[name] = {
          callCount,
          duration: endTime - startTime,
          averageCallsPerSecond: (callCount / duration) * 1000,
        };
      }, duration);
    });

    setTimeout(() => {
      console.table(results);
    }, duration + 100);
  }
}

// Test different implementations
PerformanceMonitor.compare([
  {
    name: 'No Rate Limiting',
    func: (data) => console.log('Processing:', data),
    events: { interval: 10 },
    duration: 5000,
  },
  {
    name: 'Debounced (300ms)',
    func: debounce((data) => console.log('Debounced:', data), 300),
    events: { interval: 10 },
    duration: 5000,
  },
  {
    name: 'Throttled (100ms)',
    func: throttle((data) => console.log('Throttled:', data), 100),
    events: { interval: 10 },
    duration: 5000,
  },
]);

Best Practices

1. Choose the Right Technique

// Use DEBOUNCING when:
// - You want to wait until activity stops
// - Examples: search input, window resize end, form validation

const searchInput = debounce((query) => {
  fetchSearchResults(query);
}, 300);

// Use THROTTLING when:
// - You want regular updates during activity
// - Examples: scroll position, mouse move, API rate limiting

const updateScrollProgress = throttle(() => {
  const progress = calculateScrollProgress();
  updateProgressBar(progress);
}, 50);

2. Memory Management

// Clean up event listeners
class ComponentWithRateLimiting {
  constructor() {
    this.handleResize = debounce(this.onResize.bind(this), 300);
    this.handleScroll = throttle(this.onScroll.bind(this), 100);
  }

  mount() {
    window.addEventListener('resize', this.handleResize);
    window.addEventListener('scroll', this.handleScroll);
  }

  unmount() {
    // Remove listeners
    window.removeEventListener('resize', this.handleResize);
    window.removeEventListener('scroll', this.handleScroll);

    // Cancel pending executions
    if (this.handleResize.cancel) {
      this.handleResize.cancel();
    }
  }

  onResize() {
    // Handle resize
  }

  onScroll() {
    // Handle scroll
  }
}

3. Testing Considerations

// Make rate-limited functions testable
function createTestableDebounce(func, delay) {
  let timerId;

  const debounced = function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => func.apply(this, args), delay);
  };

  // Testing utilities
  debounced.flush = function () {
    if (timerId) {
      clearTimeout(timerId);
      func.apply(this, arguments);
    }
  };

  debounced.cancel = function () {
    clearTimeout(timerId);
  };

  return debounced;
}

// In tests
describe('Debounced function', () => {
  jest.useFakeTimers();

  test('executes after delay', () => {
    const mockFn = jest.fn();
    const debounced = createTestableDebounce(mockFn, 1000);

    debounced('test');
    expect(mockFn).not.toHaveBeenCalled();

    jest.advanceTimersByTime(1000);
    expect(mockFn).toHaveBeenCalledWith('test');
  });

  test('can be flushed immediately', () => {
    const mockFn = jest.fn();
    const debounced = createTestableDebounce(mockFn, 1000);

    debounced('test');
    debounced.flush();

    expect(mockFn).toHaveBeenCalledWith('test');
  });
});

Conclusion

Debouncing and throttling are essential techniques for optimizing JavaScript applications:

Debouncing is ideal when you want to:

  • Execute a function only after activity has stopped
  • Prevent excessive API calls
  • Wait for user input to stabilize

Throttling is perfect when you need:

  • Regular updates during continuous events
  • Rate limiting for API calls
  • Smooth animations and UI updates

Key takeaways:

  • Both techniques help manage performance
  • Choose based on your specific use case
  • Implement cancel methods for cleanup
  • Consider using libraries like Lodash for production
  • Test with appropriate delays for your use case

Best practices:

  • Always clean up event listeners
  • Provide cancel methods
  • Use appropriate delays (not too short, not too long)
  • Consider combining both techniques when needed
  • Monitor performance impact

Master these techniques to create performant, responsive web applications that provide excellent user experiences!