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>
);
} <script setup>
import { ref, onBeforeUnmount } from 'vue';
const query = ref('');
const results = ref([]);
let timeoutId = null;
function handleInput(event) {
query.value = event.target.value;
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
const response = await fetch(`/api/search?q=${query.value}`);
results.value = await response.json();
}, 300);
}
onBeforeUnmount(() => clearTimeout(timeoutId));
</script>
<template>
<div>
<input :value="query" @input="handleInput" placeholder="Search..." />
<ul><li v-for="item in results" :key="item.id">{{ item.name }}</li></ul>
</div>
</template> <script>
import { onDestroy } from 'svelte';
let query = '';
let results = [];
let timeoutId;
function handleInput(event) {
query = event.target.value;
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
const response = await fetch(`/api/search?q=${query}`);
results = await response.json();
}, 300);
}
onDestroy(() => clearTimeout(timeoutId));
</script>
<div>
<input value={query} on:input={handleInput} placeholder="Search..." />
<ul>{#each results as item}<li>{item.name}</li>{/each}</ul>
</div> function debounce(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
class SearchInput extends HTMLElement {
connectedCallback() {
this.innerHTML = `<input type="text" placeholder="Search..." /><ul class="results"></ul>`;
const input = this.querySelector('input');
const resultsList = this.querySelector('.results');
const debouncedSearch = debounce(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
resultsList.innerHTML = results.map(item => `<li>${item.name}</li>`).join('');
}, 300);
input.addEventListener('input', (e) => debouncedSearch(e.target.value));
}
}
customElements.define('search-input', SearchInput); 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
clearTimeoutandsetTimeout. 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.