receta
Async

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     Execute

The 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 filters

Throttle

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    Exec

Unlike 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 periodically

Visual 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

FeatureDebounceThrottle
ExecutionAfter silenceDuring activity
FrequencyOnce after pauseRegular intervals
Use CaseWait for completionRate limiting
ExampleSearch inputScroll tracking
User feelsResponsive after actionContinuous feedback
API callsMinimized drasticallyControlled rate
Best forIntentional actionsContinuous 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 faster

Throttle 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 UI

Memory 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.

On this page