Debounce and Throttle
Control function execution frequency for performance and better UX
Overview
Debounce and throttle are essential techniques for controlling how often functions execute in response to high-frequency events. They prevent performance issues and reduce unnecessary API calls while maintaining responsive user experiences.
Key differences:
- Debounce: Wait for a pause in events before executing (last call wins)
- Throttle: Execute at most once per delay (the first call runs, then the latest)
Both patterns are critical for production applications dealing with user input, scroll events, window resizing, and API rate limits.
Debounce
What is Debouncing?
Debouncing delays function execution until a specified time has passed since the last invocation. It's like waiting for someone to finish typing before acting on their input.
Mental Model:
Events: a---b-c-d-----e-f-g-h---------i------
Debounce: ^ ^
(500ms) Execute ExecuteThe function only executes when events stop arriving for the specified delay period.
Leading vs Trailing Edge
import { debounce } from '@ontologyio/receta/async'
// Trailing edge (default) - execute AFTER silence
const searchTrailing = debounce(
async (query: string) => {
return searchAPI(query)
},
{ delay: 300 }
)
// Leading edge - execute IMMEDIATELY on first call, then silence
const searchLeading = debounce(
async (query: string) => {
return searchAPI(query)
},
{ delay: 300, leading: true, trailing: false }
)
// Both edges - execute on first AND last
const searchBoth = debounce(
async (query: string) => {
return searchAPI(query)
},
{ delay: 300, leading: true, trailing: true }
)Visual comparison:
Input: a-b-c-d------e-f-g----------h-i-j-k----
Trailing: ^ ^ ^
Leading: ^ ^ ^
Both: ^ ^ ^ ^ ^Replaced calls share the run that replaced them. Every call settles: calls waiting for the same run get one shared promise, so they all resolve with its result, or reject if it fails. With trailing: false, a call ignored during the delay gets the last leading result. If leading and trailing are both false, nothing can run, so calls reject.
Real-World Examples
1. Search Autocomplete (Algolia-style)
import { debounce } from '@ontologyio/receta/async'
import { match, ok, tryCatchAsync, type Result } from '@ontologyio/receta/result'
interface SearchResult {
id: string
title: string
description: string
score: number
}
// Debounced search prevents API spam while typing
const debouncedSearch = debounce(
async (query: string): Promise<Result<SearchResult[], string>> => {
if (query.trim().length < 2) {
return ok([])
}
return tryCatchAsync(
async () => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
if (!response.ok) throw new Error(`Search failed: ${response.statusText}`)
return response.json()
},
(error) => `Search error: ${error}`
)
},
{ delay: 300 }
)
// React/Vue component usage
function SearchBox() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([] as SearchResult[])
const [loading, setLoading] = useState(false)
const handleSearch = async (value: string) => {
setQuery(value)
setLoading(true)
const result = await debouncedSearch(value)
match(result, {
onOk: (data) => {
setResults(data)
setLoading(false)
},
onErr: (error) => {
console.error(error)
setResults([])
setLoading(false)
}
})
}
return (
<input
type="search"
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
)
}Why 300ms? Research shows users perceive responses under 300ms as instantaneous. This delay strikes a balance between responsiveness and reducing API calls.
2. Form Auto-Save (Google Docs-style)
import { debounce } from '@ontologyio/receta/async'
import { match, tryCatchAsync, type Result } from '@ontologyio/receta/result'
interface Document {
id: string
content: string
lastSaved: Date
}
interface AutoSaveOptions {
onSaving?: () => void
onSaved?: (doc: Document) => void
onError?: (error: string) => void
}
function createAutoSave(
documentId: string,
options: AutoSaveOptions = {}
) {
let latestContent = ''
const persist = async (content: string): Promise<Result<Document, string>> => {
options.onSaving?.()
return tryCatchAsync(
async () => {
const response = await fetch(`/api/documents/${documentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
})
if (!response.ok) {
throw new Error(`Save failed: ${response.statusText}`)
}
return response.json()
},
(error) => `Failed to save document: ${error}`
)
}
// Debounce with 2 second delay - wait for user to pause typing
const saveDocument = debounce(persist, { delay: 2000 })
return {
save: async (content: string) => {
latestContent = content
const result = await saveDocument(content)
match(result, {
onOk: (doc) => options.onSaved?.(doc),
onErr: (error) => options.onError?.(error)
})
},
// Save right away (e.g., on window close)
// debounce has no flush, so this skips it and calls persist directly
saveNow: () => persist(latestContent)
}
}
// Usage
const autoSave = createAutoSave('doc-123', {
onSaving: () => console.log('Saving...'),
onSaved: (doc) => console.log('Saved at', doc.lastSaved),
onError: (err) => console.error(err)
})
// User types - auto-saves after 2s of inactivity
editor.onChange((content) => autoSave.save(content))
// User closes window - save immediately
window.addEventListener('beforeunload', () => autoSave.saveNow())3. API Call Optimization
import { debounce } from '@ontologyio/receta/async'
import { tryCatchAsync, type Result } from '@ontologyio/receta/result'
// Prevent duplicate API calls when user rapidly changes filters
interface ProductFilters {
category?: string
priceMin?: number
priceMax?: number
inStock?: boolean
}
const fetchProducts = debounce(
async (filters: ProductFilters): Promise<Result<Product[], string>> => {
const params = new URLSearchParams()
if (filters.category) params.set('category', filters.category)
if (filters.priceMin) params.set('price_min', String(filters.priceMin))
if (filters.priceMax) params.set('price_max', String(filters.priceMax))
if (filters.inStock) params.set('in_stock', 'true')
return tryCatchAsync(
async () => {
const response = await fetch(`/api/products?${params}`)
if (!response.ok) throw new Error('Failed to fetch products')
return response.json()
},
(error) => `Product fetch failed: ${error}`
)
},
{ delay: 500 }
)
// User adjusts multiple filters rapidly
// Only makes ONE API call 500ms after they stop
updateFilter('category', 'electronics') // Scheduled
updateFilter('priceMax', 1000) // Cancels previous, reschedules
updateFilter('inStock', true) // Cancels previous, reschedules
// ... 500ms pause ...
// NOW executes with all filtersThrottle
What is Throttling?
Throttling ensures a function executes at most once per specified time interval. It's like a rate limiter that guarantees regular execution during continuous events.
Mental Model:
Events: a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p
Throttle: ^ ^ ^ ^ ^
(200ms) Exec Exec Exec Exec ExecUnlike debounce, throttle executes periodically during ongoing events.
Leading vs Trailing Edge
import { throttle } from '@ontologyio/receta/async'
// Both edges (default) - execute FIRST call immediately, then LAST call in the delay
const trackScrollBoth = throttle(
async (position: number) => {
analytics.track('scroll', { position })
},
{ delay: 1000 }
)
// Leading edge - execute FIRST call immediately
const trackScrollLeading = throttle(
async (position: number) => {
analytics.track('scroll', { position })
},
{ delay: 1000, trailing: false }
)
// Trailing edge - execute LAST call in the delay
const trackScrollTrailing = throttle(
async (position: number) => {
analytics.track('scroll', { position })
},
{ delay: 1000, leading: false }
)Visual comparison:
Input: a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p
Leading: ^ ^ ^ ^ ^
Trailing: ^ ^ ^ ^
Both: ^ ^ ^ ^ ^ ^ ^ ^ ^Calls made during the delay don't queue up. They share one promise for the next trailing run, which uses the latest arguments, so earlier arguments are dropped.
Real-World Examples
1. Scroll Event Tracking (Analytics)
import { throttle } from '@ontologyio/receta/async'
interface ScrollMetrics {
scrollY: number
scrollPercent: number
viewportHeight: number
documentHeight: number
}
// Track scroll position for analytics (Segment/Mixpanel style)
const trackScrollPosition = throttle(
async (metrics: ScrollMetrics) => {
// Send to analytics only once per second
window.analytics?.track('Page Scrolled', {
scroll_depth: metrics.scrollPercent,
position_y: metrics.scrollY,
viewport_height: metrics.viewportHeight,
document_height: metrics.documentHeight
})
},
{ delay: 1000 }
)
window.addEventListener('scroll', () => {
const scrollY = window.scrollY
const viewportHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
const scrollPercent = Math.round((scrollY / (documentHeight - viewportHeight)) * 100)
trackScrollPosition({
scrollY,
scrollPercent,
viewportHeight,
documentHeight
})
})2. Progress Updates (Real-time Dashboards)
import { throttle } from '@ontologyio/receta/async'
interface UploadProgress {
loaded: number
total: number
percent: number
}
// Update UI only every 100ms to prevent excessive re-renders
const updateProgressUI = throttle(
async (progress: UploadProgress) => {
const bar = document.querySelector('.progress-bar')
const text = document.querySelector('.progress-text')
// Update progress bar
if (bar) bar.style.width = `${progress.percent}%`
if (text) text.textContent =
`${progress.loaded.toLocaleString()} / ${progress.total.toLocaleString()} bytes`
},
{ delay: 100 }
)
async function uploadFile(file: File) {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
updateProgressUI({
loaded: e.loaded,
total: e.total,
percent: Math.round((e.loaded / e.total) * 100)
})
}
})
return new Promise((resolve, reject) => {
xhr.addEventListener('load', () => resolve(xhr.response))
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
xhr.open('POST', '/api/upload')
xhr.send(formData)
})
}3. Rate-Limited Webhooks
import { throttle } from '@ontologyio/receta/async'
import { tryCatchAsync, type Result } from '@ontologyio/receta/result'
interface WebhookPayload {
event: string
data: Record<string, unknown>
timestamp: Date
}
// Send webhooks at most once per 5 seconds to respect rate limits
const sendWebhook = throttle(
async (payload: WebhookPayload): Promise<Result<void, string>> => {
return tryCatchAsync(
async () => {
const response = await fetch(process.env.WEBHOOK_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Secret': process.env.WEBHOOK_SECRET!
},
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(`Webhook failed: ${response.statusText}`)
}
},
(error) => `Webhook delivery failed: ${error}`
)
},
{ delay: 5000 }
)
// Events fire rapidly, but webhook sends at most every 5s
// Events in between are dropped, so use this only when the latest one is enough
eventEmitter.on('user.updated', (user) => {
sendWebhook({
event: 'user.updated',
data: { userId: user.id, email: user.email },
timestamp: new Date()
})
})4. Window Resize Handling
import { throttle } from '@ontologyio/receta/async'
interface ViewportSize {
width: number
height: number
breakpoint: 'mobile' | 'tablet' | 'desktop'
}
const getBreakpoint = (width: number): ViewportSize['breakpoint'] => {
if (width < 768) return 'mobile'
if (width < 1024) return 'tablet'
return 'desktop'
}
// Recalculate layout only once per 200ms during resize
const handleResize = throttle(
async () => {
const viewport: ViewportSize = {
width: window.innerWidth,
height: window.innerHeight,
breakpoint: getBreakpoint(window.innerWidth)
}
// Update responsive components
store.dispatch({ type: 'viewport/update', payload: viewport })
// Re-render charts, grids, etc.
chartManager.resize(viewport)
},
{ delay: 200 }
)
window.addEventListener('resize', handleResize)Debounce vs Throttle Comparison
Side-by-Side Behavior
User Action: Typing "hello world"
Timeline: h-e-l-l-o- -w-o-r-l-d--------
Debounce (300ms):
Cancel each time until pause
^ Execute (after typing stops)
Throttle (300ms):
^ ^ ^ Execute periodicallyVisual Comparison
Event Stream: ●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●--------
(continuous events) (silence)
DEBOUNCE (500ms):
wait wait wait wait wait EXECUTE!
└─────────────────────────────┘
"Let me finish first"
THROTTLE (500ms):
EXEC EXEC EXEC
└───┘ └───┘ └───┘
"Regular updates please"Feature Comparison Table
| Feature | Debounce | Throttle |
|---|---|---|
| Execution | After silence | During activity |
| Frequency | Once after pause | Regular intervals |
| Use Case | Wait for completion | Rate limiting |
| Example | Search input | Scroll tracking |
| User feels | Responsive after action | Continuous feedback |
| API calls | Minimized drastically | Controlled rate |
| Best for | Intentional actions | Continuous events |
When to Use Which
Use Debounce When:
✅ User needs to "finish" an action (typing, dragging, resizing) ✅ You want to wait for a pause before responding ✅ Final state is what matters, not intermediate states ✅ Examples: search, auto-save, form validation
Use Throttle When:
✅ Continuous events need periodic sampling ✅ You want guaranteed execution during activity ✅ Intermediate states matter for feedback ✅ Examples: scroll tracking, progress updates, analytics
Real-World Patterns
Pattern 1: Search with Loading States
import { debounce } from '@ontologyio/receta/async'
import { match, ok, tryCatchAsync, type Result } from '@ontologyio/receta/result'
interface SearchState {
query: string
results: SearchResult[]
loading: boolean
error: string | null
}
const createSearchManager = () => {
let state: SearchState = {
query: '',
results: [],
loading: false,
error: null
}
const performSearch = debounce(
async (query: string): Promise<Result<SearchResult[], string>> => {
// Don't search empty queries
if (query.trim().length === 0) {
return ok([])
}
const result = await tryCatchAsync(
async () => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
if (!response.ok) throw new Error('Search failed')
return response.json()
},
(error) => String(error)
)
return result
},
{ delay: 300 }
)
return {
search: async (query: string) => {
state.query = query
state.loading = true
state.error = null
updateUI(state)
const result = await performSearch(query)
match(result, {
onOk: (results) => {
state.results = results
state.loading = false
updateUI(state)
},
onErr: (error) => {
state.error = error
state.loading = false
state.results = []
updateUI(state)
}
})
}
}
}Pattern 2: Form Auto-Save with Conflict Detection
import { debounce } from '@ontologyio/receta/async'
import { match, tryCatchAsync, type Result } from '@ontologyio/receta/result'
interface DocumentVersion {
id: string
content: string
version: number
lastModified: Date
}
const createAutoSaveManager = (documentId: string) => {
let currentVersion = 0
const persist = async (
content: string,
expectedVersion: number
): Promise<Result<DocumentVersion, string>> => {
return tryCatchAsync(
async () => {
const response = await fetch(`/api/documents/${documentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content,
version: expectedVersion
})
})
if (response.status === 409) {
throw new Error('Document was modified by another user')
}
if (!response.ok) {
throw new Error('Save failed')
}
const saved: DocumentVersion = await response.json()
currentVersion = saved.version
return saved
},
(error) => String(error)
)
}
const saveDocument = debounce(persist, { delay: 2000 })
return {
save: async (content: string) => {
const result = await saveDocument(content, currentVersion)
return match(result, {
onOk: (doc) => ({
success: true,
message: `Saved at ${doc.lastModified.toLocaleTimeString()}`
}),
onErr: (error) => ({
success: false,
message: error
})
})
},
saveNow: (content: string) => persist(content, currentVersion)
}
}Pattern 3: Infinite Scroll with Throttled Loading
import { throttle } from '@ontologyio/receta/async'
import { match, type Result } from '@ontologyio/receta/result'
interface PaginatedResponse<T> {
items: T[]
nextCursor: string | null
hasMore: boolean
}
const createInfiniteScroll = <T>(
fetchPage: (cursor: string | null) => Promise<Result<PaginatedResponse<T>, string>>
) => {
let loading = false
let cursor: string | null = null
let hasMore = true
const checkAndLoad = throttle(
async () => {
// Don't load if already loading or no more data
if (loading || !hasMore) return
// Check if near bottom (within 200px)
const scrollBottom = window.scrollY + window.innerHeight
const documentHeight = document.documentElement.scrollHeight
if (documentHeight - scrollBottom < 200) {
loading = true
const result = await fetchPage(cursor)
match(result, {
onOk: (response) => {
// Append new items to DOM
renderItems(response.items)
cursor = response.nextCursor
hasMore = response.hasMore
loading = false
},
onErr: (error) => {
console.error('Failed to load more:', error)
loading = false
}
})
}
},
{ delay: 200 }
)
window.addEventListener('scroll', checkAndLoad)
return {
destroy: () => window.removeEventListener('scroll', checkAndLoad)
}
}Performance Impact
Debounce Performance Benefits
// WITHOUT debounce
// User types "macbook pro 16" (15 characters)
// = 15 API calls = ~750ms total time
input.addEventListener('input', (e) => {
searchAPI(e.target.value) // Fires 15 times!
})
// WITH debounce (300ms)
// User types "macbook pro 16" (15 characters)
// = 1 API call = ~50ms total time
const debouncedSearch = debounce(searchAPI, { delay: 300 })
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value) // Fires once!
})
// Savings: 93% fewer API calls, 10x fasterThrottle Performance Benefits
// WITHOUT throttle
// User scrolls 1000px in 2 seconds
// Scroll fires ~200 times per second
// = 400 analytics events
window.addEventListener('scroll', () => {
trackScrollPosition() // Fires 400 times!
})
// WITH throttle (100ms)
// User scrolls 1000px in 2 seconds
// = 20 analytics events (one per 100ms)
const throttledTracker = throttle(trackScrollPosition, { delay: 100 })
window.addEventListener('scroll', throttledTracker) // Fires 20 times
// Savings: 95% fewer events, much smoother UIMemory and CPU Impact
import { debounce, throttle } from '@ontologyio/receta/async'
// BAD: Creates new debounced function on every render
function Component() {
const handleSearch = debounce(async (q: string) => search(q), { delay: 300 })
// ❌ New instance each render = memory leak
}
// GOOD: Stable reference
function Component() {
const handleSearch = useMemo(
() => debounce(async (q: string) => search(q), { delay: 300 }),
[]
)
// ✅ Single instance = efficient
}
// Or with a ref
function Component() {
const handleSearchRef = useRef(
debounce(async (q: string) => search(q), { delay: 300 })
)
return <input onChange={(e) => handleSearchRef.current(e.target.value)} />
}Best Practices
1. Choose Appropriate Delays
// Search input: 200-400ms (feels instant, reduces API calls)
const search = debounce(searchAPI, { delay: 300 })
// Auto-save: 1-3s (balance between data loss and API load)
const autoSave = debounce(saveDocument, { delay: 2000 })
// Scroll tracking: 100-200ms (smooth but not excessive)
const trackScroll = throttle(analytics.track, { delay: 100 })
// Window resize: 100-200ms (responsive layout updates)
const handleResize = throttle(updateLayout, { delay: 150 })
// Analytics: 500-1000ms (batch events, reduce data volume)
const trackEvent = throttle(sendAnalytics, { delay: 1000 })2. Provide User Feedback
import { match } from '@ontologyio/receta/result'
import { debounce } from '@ontologyio/receta/async'
const searchWithFeedback = debounce(
async (query: string) => {
// Show searching state
setSearching(true)
const result = await searchAPI(query)
// Show results or error
match(result, {
onOk: (data) => {
setResults(data)
setSearching(false)
},
onErr: (error) => {
setError(error)
setSearching(false)
}
})
},
{ delay: 300 }
)
// UI shows: "Searching..." → "Found 42 results" or "No results"3. Handle Cleanup Properly
debounce and throttle return a plain function, with no cancel or flush. A pending call still runs after the component is gone, so ignore its result:
import { debounce } from '@ontologyio/receta/async'
// React example
function useSearch() {
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
return useMemo(
() =>
debounce(
async (query: string) => {
const results = await search(query)
if (mounted.current) setResults(results)
},
{ delay: 300 }
),
[]
)
}Summary
Debounce
- Purpose: Wait for user to finish before acting
- Best for: Search, auto-save, form validation, API calls
- Typical delays: 200-400ms (input), 1-3s (save)
- Mental model: "Let them finish typing first"
Throttle
- Purpose: Limit execution rate during continuous events
- Best for: Scroll, resize, progress, analytics
- Typical intervals: 100-200ms (UI), 500-1000ms (analytics)
- Mental model: "Regular updates, not overwhelming"
Key Takeaway
Both patterns are essential tools for production applications. Use debounce when the final value matters, use throttle when you need regular updates during continuous activity. Always consider user experience and API load when choosing delays and intervals.