Debounce
Delay function execution until a quiet period passes with no new events, collapsing rapid successive calls into a single execution.
In this article
Debounce
Problem
I’ve watched search inputs fire an API request on every keystroke as users type “javascript”, sending ten separate requests for “j”, “ja”, “jav”, “java”, and so on up to “javascript”, when only the last one matters. 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. All that extra work uses up mobile battery, fills the network with requests nobody needs, and makes interfaces feel slow, with autocomplete dropdowns flickering on every 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 core of the pattern is two statements: 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 slower or heavier API calls, where you’d rather send fewer requests.
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, which feels broken.
Throttle suits this case better. 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, which looks smooth without running the handler on every scroll event.
Debounce suits the final state after activity stops (search queries, form validation, resize calculations). Throttle suits 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=${encodeURIComponent(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=${encodeURIComponent(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 = $state('');
let results = $state([]);
let timeoutId;
function handleInput(event) {
query = event.target.value;
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
results = await response.json();
}, 300);
}
onDestroy(() => clearTimeout(timeoutId));
</script>
<div>
<input value={query} oninput={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=${encodeURIComponent(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);
// True while the user is typing and the debounced value hasn't caught up yet
const isPending = query !== debouncedQuery;
useEffect(() => {
if (!debouncedQuery) return;
setIsSearching(true);
fetch(`/api/search?q=${encodeURIComponent(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..." />
{(isPending || 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 means the layout is recalculated once the resize ends instead of on every event:
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 ten while they type a single word.
- Heavy 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, instead of flagging an address as invalid halfway through typing it.
- Mobile devices use less battery because heavy calculations no longer run on every rapid event.
- The pattern requires just a few lines around
clearTimeoutandsetTimeout, with no dependencies.
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 sends too many requests for slow API 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, which cuts out most of the wasted work and makes out-of-order responses less likely. Shorter delays (150-200ms) suit responsive feedback and longer delays (300-500ms) suit heavier operations. Pending timeouts need clearing on unmount to avoid memory leaks.