Debouncing vs Throttling: When to Use Each

TL;DR

Debouncing waits until user stops (search input). Throttling limits rate (scroll handler). Debounce = last call wins. Throttle = regular intervals. Use debounce for user input, throttle for continuous events.

Our search autocomplete hammered the API with every keystroke. Typing "javascript" sent 10 requests in 2 seconds. Database couldn't keep up. I added debouncing - now it waits until the user stops typing. One request instead of 10.

Debouncing and throttling solve different problems. Here's when to use each, with code you can copy.

The Problem

// Search input - fires on every keystroke
searchInput.addEventListener('input', (e) => {
    searchAPI(e.target.value); // Called 10 times for "javascript"
});

// Scroll handler - fires hundreds of times
window.addEventListener('scroll', () => {
    updateScrollPosition(); // Called 200 times scrolling down page
});

Without rate limiting:

  • Too many API calls
  • Wasted bandwidth
  • Slow UI
  • Server overload

Debouncing: Wait Until User Stops

Debouncing delays execution until activity stops:

function debounce(func, delay) {
    let timeoutId;

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

// Usage
const debouncedSearch = debounce((query) => {
    console.log('Searching for:', query);
    searchAPI(query);
}, 300);

searchInput.addEventListener('input', (e) => {
    debouncedSearch(e.target.value);
});

// User types "javascript":
// j - wait 300ms
// ja - reset timer, wait 300ms
// jav - reset timer, wait 300ms
// java - reset timer, wait 300ms
// javas - reset timer, wait 300ms
// javasc - reset timer, wait 300ms
// javascr - reset timer, wait 300ms
// javascri - reset timer, wait 300ms
// javascrip - reset timer, wait 300ms
// javascript - reset timer, wait 300ms
// (user stops typing)
// After 300ms: search API called ONCE

Visual:

User types: j-a-v-a-s-c-r-i-p-t----
Debounced:                      ✓
           (waits 300ms after last keystroke)

Throttling: Execute at Regular Intervals

Throttling limits execution to once per time period:

function throttle(func, delay) {
    let lastCall = 0;

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

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

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

window.addEventListener('scroll', throttledScroll);

// User scrolls continuously:
// Scroll fires 200 times per second
// Throttled: fires every 100ms (10 times per second)

Visual:

Scroll events: ||||||||||||||||||||||||||||||||
Throttled:     ✓   ✓   ✓   ✓   ✓   ✓   ✓   ✓
               (every 100ms)

Side-by-Side Comparison

// DEBOUNCING - Wait until user stops
const debouncedSave = debounce(() => {
    console.log('Saving...');
}, 1000);

// Type: h-e-l-l-o----
// Calls: none yet...
// After 1s of no typing: save once

// THROTTLING - Regular intervals
const throttledSave = throttle(() => {
    console.log('Saving...');
}, 1000);

// Type: h-e-l-l-o continuously
// Calls: every 1 second while typing
// Multiple saves happen

Key difference:

  • Debounce: Last call wins (waits for silence)
  • Throttle: Regular intervals (continues during activity)

When to Use Debouncing

1. Search Autocomplete

const debouncedSearch = debounce(async (query) => {
    if (query.length < 2) return;

    const results = await fetch(`/api/search?q=${query}`);
    displayResults(await results.json());
}, 300);

searchInput.addEventListener('input', (e) => {
    debouncedSearch(e.target.value);
});

2. Form Validation

const debouncedValidate = debounce(async (email) => {
    const response = await fetch(`/api/validate-email?email=${email}`);
    const { valid } = await response.json();

    if (!valid) {
        showError('Email already registered');
    }
}, 500);

emailInput.addEventListener('input', (e) => {
    debouncedValidate(e.target.value);
});

3. Auto-Save

const debouncedSave = debounce(async (content) => {
    await fetch('/api/save', {
        method: 'POST',
        body: JSON.stringify({ content })
    });
    showNotification('Draft saved');
}, 2000);

editor.addEventListener('input', (e) => {
    debouncedSave(e.target.value);
});

4. Window Resize

const debouncedResize = debounce(() => {
    console.log('Window resized to:', window.innerWidth);
    adjustLayout();
}, 250);

window.addEventListener('resize', debouncedResize);

When to Use Throttling

1. Scroll Handler

const throttledScroll = throttle(() => {
    const scrollPercent = (window.scrollY / document.body.scrollHeight) * 100;
    updateProgressBar(scrollPercent);
}, 100);

window.addEventListener('scroll', throttledScroll);

2. Mouse Move Tracking

const throttledMouseMove = throttle((e) => {
    console.log('Mouse at:', e.clientX, e.clientY);
    updateCursorEffect(e.clientX, e.clientY);
}, 50);

document.addEventListener('mousemove', throttledMouseMove);

3. Infinite Scroll

const throttledCheckScroll = throttle(() => {
    const scrollBottom = window.scrollY + window.innerHeight;
    const pageHeight = document.body.scrollHeight;

    if (scrollBottom >= pageHeight - 100) {
        loadMoreItems();
    }
}, 200);

window.addEventListener('scroll', throttledCheckScroll);

4. Game Loop / Animation

const throttledUpdate = throttle(() => {
    updateGameState();
    render();
}, 16); // ~60 FPS

function gameLoop() {
    throttledUpdate();
    requestAnimationFrame(gameLoop);
}

Advanced Debouncing (Leading + Trailing)

function debounce(func, delay, options = {}) {
    let timeoutId;
    let lastCallTime = 0;

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

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

        clearTimeout(timeoutId);

        if (leading && isFirstCall) {
            func.apply(this, args);
        }

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

        lastCallTime = now;
    };
}

// Leading edge - execute immediately, then wait
const leadingDebounce = debounce(search, 300, { leading: true, trailing: false });

// Trailing edge - wait, then execute (default)
const trailingDebounce = debounce(search, 300, { leading: false, trailing: true });

// Both - execute immediately AND after delay
const bothDebounce = debounce(search, 300, { leading: true, trailing: true });

Advanced Throttling (Leading + Trailing)

function throttle(func, delay, options = {}) {
    let lastCall = 0;
    let timeoutId = null;

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

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

        if (leading && timeSinceLastCall >= delay) {
            lastCall = now;
            func.apply(this, args);
        }

        if (trailing) {
            clearTimeout(timeoutId);
            timeoutId = setTimeout(() => {
                lastCall = Date.now();
                func.apply(this, args);
            }, delay - timeSinceLastCall);
        }
    };
}

React Hooks

import { useState, useEffect, 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;
}

// Usage
function SearchComponent() {
    const [query, setQuery] = useState('');
    const debouncedQuery = useDebounce(query, 300);

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

    return (
        <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search..."
        />
    );
}
function useThrottle(callback, delay) {
    const lastRan = useRef(Date.now());

    return useCallback((...args) => {
        const now = Date.now();

        if (now - lastRan.current >= delay) {
            callback(...args);
            lastRan.current = now;
        }
    }, [callback, delay]);
}

// Usage
function ScrollComponent() {
    const handleScroll = useThrottle(() => {
        console.log('Scrolled:', window.scrollY);
    }, 100);

    useEffect(() => {
        window.addEventListener('scroll', handleScroll);
        return () => window.removeEventListener('scroll', handleScroll);
    }, [handleScroll]);

    return <div>Scroll me</div>;
}

Lodash Implementations

import { debounce, throttle } from 'lodash';

// Debounce
const debouncedSearch = debounce((query) => {
    searchAPI(query);
}, 300);

// Throttle
const throttledScroll = throttle(() => {
    updatePosition();
}, 100);

// Cancel pending calls
debouncedSearch.cancel();
throttledScroll.cancel();

// Execute immediately
debouncedSearch.flush();
throttledScroll.flush();

Real-World Examples

Search with Loading State

let searchController = null;

const debouncedSearch = debounce(async (query) => {
    // Cancel previous request
    if (searchController) {
        searchController.abort();
    }

    searchController = new AbortController();

    try {
        setLoading(true);
        const response = await fetch(`/api/search?q=${query}`, {
            signal: searchController.signal
        });
        const results = await response.json();
        displayResults(results);
    } catch (err) {
        if (err.name !== 'AbortError') {
            showError(err);
        }
    } finally {
        setLoading(false);
    }
}, 300);

Infinite Scroll with Throttle

let loading = false;

const loadMore = throttle(async () => {
    if (loading) return;

    const scrollBottom = window.scrollY + window.innerHeight;
    const pageHeight = document.body.scrollHeight;

    if (scrollBottom >= pageHeight - 200) {
        loading = true;
        const items = await fetchMoreItems();
        appendItems(items);
        loading = false;
    }
}, 200);

window.addEventListener('scroll', loadMore);

Form Auto-Save with Status

let saveStatus = 'saved'; // 'saving', 'saved', 'error'

const debouncedSave = debounce(async (content) => {
    try {
        saveStatus = 'saving';
        updateUI('Saving...');

        await fetch('/api/save', {
            method: 'POST',
            body: JSON.stringify({ content })
        });

        saveStatus = 'saved';
        updateUI('All changes saved');
    } catch (err) {
        saveStatus = 'error';
        updateUI('Error saving changes');
    }
}, 2000);

editor.addEventListener('input', (e) => {
    updateUI('Unsaved changes');
    debouncedSave(e.target.value);
});

Performance Comparison

// Without debouncing - 10 API calls
function testWithoutDebounce() {
    let calls = 0;
    const search = () => calls++;

    'javascript'.split('').forEach(() => search());
    console.log('API calls:', calls); // 10
}

// With debouncing - 1 API call
function testWithDebounce() {
    let calls = 0;
    const search = debounce(() => calls++, 300);

    'javascript'.split('').forEach(() => search());

    setTimeout(() => {
        console.log('API calls:', calls); // 1
    }, 400);
}

Savings:

Without: 10 requests × 50ms = 500ms total
With: 1 request × 50ms = 50ms total
Result: 90% reduction in API calls

Common Mistakes

Mistake 1: Creating New Debounced Function Each Render

// BAD - Creates new function every render
function SearchComponent() {
    const handleSearch = debounce((query) => {
        searchAPI(query);
    }, 300); // New function every time!

    return <input onChange={(e) => handleSearch(e.target.value)} />;
}

// GOOD - Memoize the function
function SearchComponent() {
    const handleSearch = useCallback(
        debounce((query) => {
            searchAPI(query);
        }, 300),
        []
    );

    return <input onChange={(e) => handleSearch(e.target.value)} />;
}

Mistake 2: Not Canceling on Unmount

// BAD - Continues after component unmounts
useEffect(() => {
    const debouncedFn = debounce(() => {
        updateState();
    }, 300);

    window.addEventListener('resize', debouncedFn);
    return () => window.removeEventListener('resize', debouncedFn);
}, []);

// GOOD - Cancel on unmount
useEffect(() => {
    const debouncedFn = debounce(() => {
        updateState();
    }, 300);

    window.addEventListener('resize', debouncedFn);

    return () => {
        window.removeEventListener('resize', debouncedFn);
        debouncedFn.cancel(); // Cancel pending calls
    };
}, []);

Mistake 3: Wrong Delay Times

// Too short - still too many calls
debounce(search, 10); // Fires almost every keystroke

// Too long - feels laggy
debounce(search, 2000); // 2 second delay feels broken

// Good delays:
debounce(search, 300);      // Search: 300ms
debounce(autoSave, 2000);   // Auto-save: 2s
throttle(scroll, 100);      // Scroll: 100ms
throttle(mousemove, 50);    // Mouse: 50ms

Testing

describe('debounce', () => {
    jest.useFakeTimers();

    it('should delay function execution', () => {
        const func = jest.fn();
        const debounced = debounce(func, 300);

        debounced();
        expect(func).not.toHaveBeenCalled();

        jest.advanceTimersByTime(300);
        expect(func).toHaveBeenCalledTimes(1);
    });

    it('should reset timer on multiple calls', () => {
        const func = jest.fn();
        const debounced = debounce(func, 300);

        debounced();
        jest.advanceTimersByTime(200);
        debounced();
        jest.advanceTimersByTime(200);
        debounced();

        expect(func).not.toHaveBeenCalled();

        jest.advanceTimersByTime(300);
        expect(func).toHaveBeenCalledTimes(1);
    });
});

Decision Tree

Is the event triggered repeatedly?
├─ Yes: Need rate limiting
│  ├─ Should it execute during activity?
│  │  ├─ Yes: Use THROTTLE
│  │  │  └─ Examples: scroll, mousemove, resize during drag
│  │  └─ No: Use DEBOUNCE
│  │     └─ Examples: search input, form validation, auto-save
│  └─ Should it execute immediately?
│     ├─ Yes: leading: true
│     └─ No: trailing: true (default)
└─ No: No rate limiting needed

Quick Reference

// DEBOUNCE - Wait for pause
debounce(func, delay)
✓ Search autocomplete
✓ Form validation
✓ Auto-save
✓ Window resize
✓ API calls on input

// THROTTLE - Regular intervals
throttle(func, delay)
✓ Scroll handlers
✓ Mouse tracking
✓ Infinite scroll
✓ Game loops
✓ Progress indicators

The Bottom Line

Debouncing waits for user to stop. Throttling limits execution rate.

Use debounce for user input - search, validation, auto-save. Waits until they're done.

Use throttle for continuous events - scroll, mousemove, resize. Executes during activity.

Common delays: 300ms for search, 100ms for scroll, 2s for auto-save.

Our search sent 10 API calls per query. Added debouncing, now it's 1 call. Server happy, users happy, problem solved.

Pick the right tool for your use case. Copy these implementations. Test the delays. Your app will feel more responsive.