Skip to main content
Saved
Pattern
Difficulty Intermediate

Debounce

Delay function execution until a quiet period passes with no new events, collapsing rapid successive calls into a single execution.

By Den Odell Added

Debounce

Problem

I’ve watched search inputs fire an API request on every keystroke as users type “javascript”, sending seven separate requests for “j”, “ja”, “jav”, “java”, “javas”, “javasc”, and “javascri” before they finish the word. Each request wastes bandwidth, hammers the server, and creates race conditions where responses arrive out of order and wrong results end up on screen.

The same problem appears everywhere rapid events occur: resize handlers that recalculate layouts 60+ times per second and freeze the UI, form validation that shows premature “email invalid” errors after entering just “user@” when the user clearly intends to complete the address. These wasted computations drain mobile batteries, saturate network connections, and create sluggish interfaces where autocomplete dropdowns flicker frantically with each keystroke.

Solution

Debouncing delays function execution until a quiet period passes with no new events. When an event fires, it starts a timer (typically 150-500ms); if another event fires before completion, it cancels the previous timer and starts fresh. The function only executes when the timer expires without interruption.

The underlying pattern is deceptively simple: clearTimeout(timeoutId); timeoutId = setTimeout(fn, delay); cancels any pending execution and schedules a new one. For search inputs, the API request fires once after typing stops; for resize handlers, layout recalculation happens once after the gesture completes.

Shorter delays (150-200ms) work well for responsive autocomplete, while longer delays (300-500ms) suit expensive API calls where you want to be more conservative about triggering work.

The Wrong Choice

Debounce is wrong when you need periodic updates during continuous activity. A scroll-spy that highlights the current section is a classic example. If you debounce the scroll handler, users see no feedback while scrolling and then a sudden jump once they stop. That feels broken.

The fix is throttle. While debounce waits for quiet and fires once at the end, throttle fires at regular intervals during activity, guaranteeing at most one execution per time window. A 100ms throttle updates scroll-spy roughly 10 times per second, smooth and responsive without overwhelming the browser.

Use debounce for final state after activity stops (search queries, form validation, resize calculations). Use throttle for periodic feedback during ongoing activity (scroll position, mouse movement, progress updates).

Example

These examples show debounced search handlers across frameworks. The core pattern is the same everywhere.

Basic Debounce Utility

import { useState, useEffect } from 'react';

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

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

  return debouncedValue;
}

function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const debouncedQuery = useDebouncedValue(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`)
        .then(res => res.json())
        .then(setResults);
    }
  }, [debouncedQuery]);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
      <ul>{results.map(item => <li key={item.id}>{item.name}</li>)}</ul>
    </div>
  );
}

Debounce with Loading State

Adding a loading indicator while the debounced function is pending helps users understand that their input was received:

function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isSearching, setIsSearching] = useState(false);
  const debouncedQuery = useDebouncedValue(query, 300);

  useEffect(() => {
    if (!debouncedQuery) return;
    setIsSearching(true);
    fetch(`/api/search?q=${debouncedQuery}`)
      .then(res => res.json())
      .then(data => { setResults(data); setIsSearching(false); });
  }, [debouncedQuery]);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
      {isSearching && <span>Searching...</span>}
      <ul>{results.map(item => <li key={item.id}>{item.name}</li>)}</ul>
    </div>
  );
}

Debounced Window Resize Handler

For window events that fire rapidly, debouncing prevents layout thrashing:

const debouncedResize = debounce(() => {
  recalculateLayout();
  updateResponsiveElements();
}, 150);

window.addEventListener('resize', debouncedResize);
window.removeEventListener('resize', debouncedResize); // cleanup

Benefits

  • Search inputs send one request after the user pauses instead of seven while they type a single word.
  • Expensive resize handlers run once after the gesture completes rather than dozens of times per second, keeping the UI responsive.
  • Race conditions from out-of-order responses become rare because each request represents final intent rather than intermediate state.
  • Form validation waits until users pause before showing errors. No more yelling at them mid-keystroke.
  • Mobile battery life improves because you stop running expensive calculations on every rapid event.
  • The pattern requires just a few lines around clearTimeout and setTimeout. No dependencies needed.

Tradeoffs

  • Inherent latency: feedback is always delayed by at least the debounce delay, regardless of how fast your operation is.
  • Choosing the right delay requires balancing responsiveness and efficiency. 150ms feels snappy for autocomplete but wasteful for expensive calls, while 500ms suits server requests but feels slow for simple updates.
  • Timer management is easy to get wrong; forgetting to clear timeouts on unmount causes memory leaks and stale state updates that are hard to debug.
  • Slow typists may trigger multiple requests if their natural cadence includes pauses longer than your delay. Debouncing reduces volume rather than guaranteeing a single request.
  • The delay can feel unresponsive, so you often need immediate visual feedback like a “searching…” indicator while waiting for the debounced action.
  • Debouncing is wrong for guaranteed rate limiting: continuous typing without pauses never triggers the function until the user stops.

Summary

Debouncing collapses rapid successive calls into a single execution by waiting for a quiet period, dramatically reducing wasted work and preventing race conditions. Use shorter delays (150-200ms) for responsive feedback and longer delays (300-500ms) for expensive operations. Always clean up pending timeouts on unmount to avoid memory leaks.

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.