DebounceThrottlePerformanceEvent HandlingRate Limiting

Debounce & Throttle

Master the two essential techniques for controlling high-frequency events. Understand when to wait for the user to stop (debounce) vs when to limit execution rate (throttle) — and why picking the wrong one causes bugs.

28 min read13 sections
01

Overview

Browsers fire certain events at extremely high rates — a scroll handler runs dozens of times per second, a keypress handler fires on every character, and a resize handler triggers continuously while the user drags the window edge. Running expensive logic (API calls, DOM calculations, re-renders) on every single event is wasteful and often harmful.

Debounce delays execution until the user stops triggering the event for a set period. Type "react hooks" in a search box and the API call fires once — after you stop typing — not 11 times. Throttle limits execution to at most once per interval. Scroll a page and the position tracker fires every 200ms — not 60 times per second.

Both are rate-limiting techniques, but they solve different problems. Picking the wrong one leads to either sluggish UX (debounce where throttle is needed) or wasted work (throttle where debounce is needed). Understanding the distinction is a core frontend interview skill.

Why this matters

"How would you optimize a search input?" and "How would you handle scroll events efficiently?" are two of the most common frontend interview questions. The answer is always debounce or throttle — knowing which one and why is what interviewers are testing.

02

The Problem: High-Frequency Events

Some browser events fire far more often than you'd expect. Without rate limiting, attaching expensive logic to these events creates real problems.

⌨️

Typing (onChange)

Fires on every keystroke. Typing 'react hooks' = 11 events. If each triggers an API call, that's 11 network requests for one search query.

📜

Scrolling (onScroll)

Fires 30–60 times per second during scroll. A 2-second scroll = 60–120 events. Running layout calculations on each one causes visible jank.

↔️

Resizing (onResize)

Fires continuously while the user drags the window edge. A 1-second resize = 30+ events. Recalculating layouts on each one freezes the UI.

problem-no-optimization.jsjavascript
// ❌ No optimization — API call on EVERY keystroke

const searchInput = document.getElementById("search");

searchInput.addEventListener("input", (e) => {
  // This fires on EVERY character typed
  fetch(`/api/search?q=${e.target.value}`)
    .then(res => res.json())
    .then(results => renderResults(results));
});

// User types "react hooks":
// r        → fetch("/api/search?q=r")
// re       → fetch("/api/search?q=re")
// rea      → fetch("/api/search?q=rea")
// reac     → fetch("/api/search?q=reac")
// react    → fetch("/api/search?q=react")
// react    → fetch("/api/search?q=react ")
// react h  → fetch("/api/search?q=react h")
// react ho → fetch("/api/search?q=react ho")
// react hoo→ fetch("/api/search?q=react hoo")
// react hook→fetch("/api/search?q=react hook")
// react hooks→fetch("/api/search?q=react hooks")
//
// 11 API calls! 10 of them are useless.
// The server is overloaded. Results flicker as responses
// arrive out of order. The user only wanted ONE search.
Scroll Event Frequencytext
User scrolls for 2 seconds:

Without throttle:
scrollscrollscrollscrollscroll ║ ...
  0ms      16ms     32ms     48ms     64ms     80ms
  
120 handler executions in 2 seconds
If each handler reads DOM layout: 120 forced reflows
Result: janky, stuttering scroll

With throttle (200ms):
scroll ║                   ║ scroll ║                   ║ ...
  0ms                          200ms                        400ms
  
10 handler executions in 2 seconds
Smooth scrolling, responsive UI

The core problem

The event fires at the browser's rate, but your logic should run at the user's rate. The user typed one query, not eleven. The user scrolled one page, not sixty frames. Debounce and throttle bridge this gap.

03

What Is Debouncing?

Debouncing delays the execution of a function until the user stops triggering the event for a specified period. Every time the event fires, the timer resets. The function only runs once — after the last event, when the user is "done."

Debounce Timelinetext
User types "react" with 300ms debounce delay:

Events:    r       e       a       c       t
Time:      0ms     150ms   280ms   400ms   520ms
Timer:     start   reset   reset   reset   reset

                                            └─ 300ms of silence...

Execute:                                            820msfires ONCE

What happens at each keystroke:
  "r"start 300ms timer
  "e"cancel old timer, start new 300ms timer
  "a"cancel old timer, start new 300ms timer
  "c"cancel old timer, start new 300ms timer
  "t"cancel old timer, start new 300ms timer
  ...300ms pass with no more keystrokes...
Timer fires! Execute function with value "react"

Result: 1 execution instead of 5. The function waits for the
user to finish, then runs with the final value.

When to Use Debounce

  • Search input → wait for user to finish typing, then call API
  • Form validation → validate after user stops editing a field
  • Auto-save → save after user stops making changes
  • Window resize → recalculate layout after user finishes resizing
  • Autocomplete → show suggestions after typing pauses

The mental model

Think of debounce like an elevator door. Every time someone approaches (event), the door stays open (timer resets). The door only closes (function executes) after nobody has approached for a set period. It waits for the "final" event.

04

What Is Throttling?

Throttling ensures a function executes at most once per specified time interval, regardless of how many times the event fires. Unlike debounce, throttle doesn't wait for the user to stop — it runs immediately, then ignores subsequent calls until the interval passes.

Throttle Timelinetext
Scroll events with 200ms throttle:

Events:    ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓
Time:      0  16 32 48 64 80 ... 200    ... 400    ... 600ms

Execute:   ✓                         ✓           ✓           ✓
           0ms                       200ms       400ms       600ms

What happens:
  0msExecute immediately (first call always runs)
  16msIgnored (within 200ms window)
  32msIgnored
  ...
  200ms → 200ms passed since last executionExecute
  216msIgnored
  ...
  400msExecute
  ...

Result: ~3 executions per second instead of ~60.
The function runs at a steady, predictable rate.
The user gets continuous feedback, not delayed feedback.

When to Use Throttle

  • Scroll position tracking → update progress bar or lazy-load images at a steady rate
  • Mouse move → update tooltip position or drag handler without overwhelming the browser
  • Window resize → adjust layout continuously but at a controlled rate
  • Button click spam → prevent double-submit by ignoring rapid clicks
  • Analytics events → send scroll depth or interaction data at fixed intervals

The mental model

Think of throttle like a metronome. No matter how fast you play (events fire), the metronome ticks at a fixed rate (function executes). You get consistent, evenly-spaced executions — not silence followed by a burst.

05

Debounce vs Throttle

This is the section interviewers care about most. You need to clearly articulate when to use each and why.

DebounceThrottle
When it runsAfter the LAST event + delayAt most once per interval
During rapid eventsKeeps resetting, never firesFires at fixed intervals
First eventDelayed (waits for silence)Immediate (runs right away)
User feedbackDelayed — feels like a pauseContinuous — feels responsive
GuaranteesRuns exactly once after activity stopsRuns at a steady rate during activity
Best forFinal value matters (search, save)Continuous updates matter (scroll, resize)
Side-by-Side Comparisontext
Events firing rapidly over 1 second (every ~50ms):

Input:     ╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎  (20 events)
           0ms                    1000ms

Debounce (300ms delay):
Execute:                              ╎
                                      1300ms
1 execution (after events stop + 300ms)

Throttle (300ms interval):
Execute:   ╎         ╎         ╎      ╎
           0ms       300ms     600ms   900ms
4 executions (one every 300ms)

No optimization:
Execute:   ╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎╎
20 executions (every single event)

Decision Guide

Which One to Use?text
Do you need the FINAL value after the user stops?

    ├─ YESDEBOUNCE
Examples:
    │   • Search input (want the complete query)
    │   • Form validation (want the final field value)
    │   • Auto-save (want the final document state)
    │   • Resize handler (want the final window size)

    └─ NO, you need CONTINUOUS updates during the action?

        └─ YESTHROTTLE
            Examples:
Scroll tracking (need position updates while scrolling)
Drag handler (need position updates while dragging)
Mouse move (need cursor position continuously)
Progress indicator (need updates during long operation)

The one-line difference

Debounce: "Run this after the user stops doing the thing." Throttle: "Run this while the user is doing the thing, but not too often."

06

Implementation

Understanding the implementation is critical for interviews. Both are surprisingly simple — a few lines of code using timers.

Debounce Implementation

debounce.jsjavascript
function debounce(fn, delay) {
  let timeoutId;

  return function (...args) {
    // Cancel the previous timer (if any)
    clearTimeout(timeoutId);

    // Start a new timer
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// How it works:
// 1. Each call clears the previous setTimeout
// 2. A new setTimeout is set for 'delay' ms
// 3. If no new call comes within 'delay', the function executes
// 4. If a new call comes, step 1 cancels the pending timer

// Usage:
const debouncedSearch = debounce((query) => {
  fetch(`/api/search?q=${query}`);
}, 300);

input.addEventListener("input", (e) => {
  debouncedSearch(e.target.value);
});
1

Store a timer reference

The closure holds a timeoutId variable that persists across calls. This is how the function 'remembers' the pending timer.

2

Clear previous timer on each call

clearTimeout cancels the pending execution. If the user is still typing, the previous call never fires.

3

Set a new timer

setTimeout schedules the function to run after 'delay' ms. If no new call comes, this timer fires and the function executes.

Throttle Implementation

throttle.jsjavascript
function throttle(fn, interval) {
  let lastTime = 0;

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

    // Only execute if enough time has passed
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

// How it works:
// 1. Track the timestamp of the last execution
// 2. On each call, check if 'interval' ms have passed
// 3. If yes → execute and update lastTime
// 4. If no → ignore the call (do nothing)

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

window.addEventListener("scroll", throttledScroll);

Throttle with Trailing Call

throttle-with-trailing.jsjavascript
// Enhanced throttle that also fires on the trailing edge
// Ensures the last event is always captured

function throttle(fn, interval) {
  let lastTime = 0;
  let timeoutId = null;

  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - lastTime);

    if (remaining <= 0) {
      // Enough time passed → execute immediately
      clearTimeout(timeoutId);
      timeoutId = null;
      lastTime = now;
      fn.apply(this, args);
    } else if (!timeoutId) {
      // Schedule a trailing call for when the interval ends
      timeoutId = setTimeout(() => {
        lastTime = Date.now();
        timeoutId = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

// Why trailing matters:
// Without trailing: if the last event falls within the throttle window,
// it's silently dropped. The final scroll position is never captured.
// With trailing: the last event always fires after the interval,
// ensuring the final state is processed.

Interview tip

Interviewers often ask you to implement debounce or throttle from scratch. The basic versions above are 10 lines each. Memorize the pattern: debounce = clearTimeout + setTimeout. Throttle = Date.now() comparison. Then explain the trade-offs of leading vs trailing execution.

07

React Use Cases

Using debounce and throttle in React requires care — you need to ensure the debounced/throttled function persists across renders and cleans up properly.

Debounced Search Input

debounced-search.jsxjavascript
import { useState, useCallback, useEffect, useRef } from "react";

// Approach 1: Custom useDebouncedCallback hook
function useDebouncedCallback(fn, delay) {
  const timeoutRef = useRef(null);

  // Cleanup on unmount
  useEffect(() => {
    return () => clearTimeout(timeoutRef.current);
  }, []);

  return useCallback((...args) => {
    clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => {
      fn(...args);
    }, delay);
  }, [fn, delay]);
}

// Usage in a search component
function SearchBar() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  const searchAPI = useCallback(async (searchQuery) => {
    const res = await fetch(`/api/search?q=${searchQuery}`);
    const data = await res.json();
    setResults(data);
  }, []);

  const debouncedSearch = useDebouncedCallback(searchAPI, 300);

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);              // Update input immediately (responsive)
    debouncedSearch(value);       // Debounce the API call (efficient)
  };

  return (
    <div>
      <input value={query} onChange={handleChange} placeholder="Search..." />
      <ResultsList results={results} />
    </div>
  );
}

// Key insight: the INPUT updates on every keystroke (responsive UX),
// but the API call only fires after 300ms of silence (efficient).

Throttled Scroll Handler

throttled-scroll.jsxjavascript
import { useState, useEffect, useRef, useCallback } from "react";

// Custom useThrottledCallback hook
function useThrottledCallback(fn, interval) {
  const lastTimeRef = useRef(0);
  const timeoutRef = useRef(null);

  useEffect(() => {
    return () => clearTimeout(timeoutRef.current);
  }, []);

  return useCallback((...args) => {
    const now = Date.now();
    const remaining = interval - (now - lastTimeRef.current);

    if (remaining <= 0) {
      clearTimeout(timeoutRef.current);
      lastTimeRef.current = now;
      fn(...args);
    } else if (!timeoutRef.current) {
      timeoutRef.current = setTimeout(() => {
        lastTimeRef.current = Date.now();
        timeoutRef.current = null;
        fn(...args);
      }, remaining);
    }
  }, [fn, interval]);
}

// Usage: scroll progress indicator
function ScrollProgress() {
  const [progress, setProgress] = useState(0);

  const updateProgress = useCallback(() => {
    const scrolled = window.scrollY;
    const total = document.body.scrollHeight - window.innerHeight;
    setProgress(total > 0 ? (scrolled / total) * 100 : 0);
  }, []);

  const throttledUpdate = useThrottledCallback(updateProgress, 100);

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

  return (
    <div className="progress-bar" style={{ width: `${progress}%` }} />
  );
}

Using lodash or use-debounce

with-libraries.jsxjavascript
// Option 1: lodash (most common in production)
import { debounce, throttle } from "lodash";
import { useMemo, useEffect } from "react";

function SearchBar() {
  const [query, setQuery] = useState("");

  // useMemo ensures the debounced function is created once
  const debouncedFetch = useMemo(
    () => debounce((q) => fetch(`/api/search?q=${q}`), 300),
    []
  );

  // Cleanup on unmount
  useEffect(() => {
    return () => debouncedFetch.cancel();
  }, [debouncedFetch]);

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

// Option 2: use-debounce library (React-specific)
import { useDebouncedCallback } from "use-debounce";

function SearchBar() {
  const [query, setQuery] = useState("");

  const debouncedFetch = useDebouncedCallback((q) => {
    fetch(`/api/search?q=${q}`);
  }, 300);

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

The React trap

Never create a debounced function inside a component without memoizing it. Each render creates a new function with a new timer — the debounce resets on every render and never fires. Use useRef, useMemo, or useCallback to keep the same debounced function across renders.

08

Real-World Example

Let's build a search-as-you-type feature and see the difference debounce makes — with concrete numbers.

❌ Without Debounce

search-no-debounce.jsxjavascript
function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  const handleChange = async (e) => {
    const value = e.target.value;
    setQuery(value);

    // ❌ API call on EVERY keystroke
    setLoading(true);
    const res = await fetch(`/api/search?q=${value}`);
    const data = await res.json();
    setResults(data);
    setLoading(false);
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {loading && <Spinner />}
      <ResultsList results={results} />
    </div>
  );
}

// User types "javascript promises" (22 characters):
//
// Network tab:
// GET /api/search?q=j           → 200 OK (45ms)
// GET /api/search?q=ja          → 200 OK (52ms)
// GET /api/search?q=jav         → 200 OK (38ms)
// GET /api/search?q=java        → 200 OK (41ms)
// ... 18 more requests ...
// GET /api/search?q=javascript promises → 200 OK (67ms)
//
// Problems:
// 1. 22 API calls (21 are wasted)
// 2. Results flicker as responses arrive out of order
// 3. Server load: 22x more than necessary
// 4. Race condition: "java" response may arrive AFTER "javascript"
//    showing wrong results

✅ With Debounce

search-with-debounce.jsxjavascript
function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  // AbortController to cancel stale requests
  const abortRef = useRef(null);

  const searchAPI = useCallback(async (searchQuery) => {
    // Cancel previous in-flight request
    abortRef.current?.abort();
    abortRef.current = new AbortController();

    try {
      setLoading(true);
      const res = await fetch(`/api/search?q=${searchQuery}`, {
        signal: abortRef.current.signal,
      });
      const data = await res.json();
      setResults(data);
    } catch (err) {
      if (err.name !== "AbortError") throw err;
    } finally {
      setLoading(false);
    }
  }, []);

  // ✅ Debounced API call — fires 300ms after last keystroke
  const debouncedSearch = useDebouncedCallback(searchAPI, 300);

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);           // Input updates immediately
    debouncedSearch(value);    // API call is debounced
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {loading && <Spinner />}
      <ResultsList results={results} />
    </div>
  );
}

// User types "javascript promises" (22 characters):
//
// Network tab:
// GET /api/search?q=javascript promises → 200 OK (67ms)
//
// That's it. ONE request. The user typed at normal speed,
// paused for 300ms, and the debounce fired with the complete query.
//
// Improvements:
// 1. 1 API call instead of 22 (95% reduction)
// 2. No flickering results
// 3. No race conditions (AbortController cancels stale requests)
// 4. Server load reduced by 95%
MetricWithout DebounceWith Debounce (300ms)
API calls for 'javascript promises'22 requests1 request
Server load22x baseline1x baseline
Race conditionsLikelyNone
Result flickeringVisibleNone
Input responsivenessInstantInstant
Result latency~50ms per keystroke300ms after last key + API time

The trade-off

Debounce adds a small delay before results appear (the 300ms wait). This is almost always acceptable — users expect a brief pause between typing and seeing results. The alternative (22 flickering API calls) is far worse. If 300ms feels too slow, try 150ms. If it's too fast (still too many calls), try 500ms.

09

Performance Insights

Debounce and throttle are among the highest-leverage performance optimizations in frontend development. A single debounce on a search input can reduce server load by 90%+.

✓ Done

Reduce API Calls with Debounce

A 300ms debounce on a search input typically reduces API calls by 90-95%. For a search box used 1,000 times/day, that's 10,000+ fewer requests per day.

✓ Done

Smooth Scroll with Throttle

Throttling scroll handlers to 100-200ms keeps the main thread free for rendering. Without throttle, scroll handlers can consume 50%+ of frame budget, causing jank.

✓ Done

Cancel Stale Requests with AbortController

Combine debounce with AbortController to cancel in-flight requests when a new one starts. This prevents race conditions where old responses overwrite newer ones.

→ Could add

Use requestAnimationFrame for Visual Updates

For scroll/resize handlers that update the DOM, throttle to requestAnimationFrame (16ms) instead of a fixed interval. This syncs with the browser's paint cycle for the smoothest result.

→ Could add

Consider Server-Side Rate Limiting Too

Client-side debounce/throttle reduces load, but don't rely on it alone. Server-side rate limiting protects against malicious clients that bypass your frontend.

Choosing the Right Delay

Use CaseTechniqueRecommended DelayWhy
Search inputDebounce200–500msUsers pause briefly between words
Form validationDebounce300–500msValidate after user finishes a field
Auto-saveDebounce1000–2000msSave after editing pauses
Scroll trackingThrottle100–200msNeed continuous updates, not too frequent
Mouse moveThrottle50–100msNeed smooth tracking without overwhelming
Window resizeDebounce200–300msOnly need final size after resize ends
Button clickThrottle500–1000msPrevent double-submit

The rAF throttle pattern

For visual updates (scroll animations, parallax, drag), throttle to requestAnimationFrame instead of a fixed interval. This runs your handler once per frame (~16ms at 60fps), perfectly synced with the browser's paint cycle. It's the smoothest possible throttle for visual work.

10

Common Mistakes

🔄

Creating a new debounced function on every render

Each render creates a new function with a new timer. The old timer is abandoned and the new one starts fresh — so the debounce delay never completes.

Memoize the debounced function with useMemo or useRef so the same instance (and its timer) persists across renders.

🔀

Using debounce when you need throttle

Debounce waits for the user to STOP. If the user never stops (continuous scroll), the handler never fires during the action. A debounced scroll progress bar only updates after scrolling ends.

Use throttle when you need periodic updates during an ongoing event. Throttle gives continuous feedback at a controlled rate.

💾

Not cleaning up timers on unmount

If the component unmounts while a debounce timer is pending, the callback fires after unmount and tries to setState on a component that no longer exists — causing memory leaks.

Always cancel pending timers in useEffect cleanup: useEffect(() => () => debouncedFn.cancel(), [debouncedFn]).

⌨️

Debouncing the state update instead of the side effect

Debouncing setQuery makes the input feel laggy — the user types but sees nothing for 300ms. The input should always update immediately.

Debounce only the expensive side effect (API call). Update input state immediately with setQuery for responsive typing, then debounce the fetch.

11

Interview Questions

Q:What is the difference between debounce and throttle?

A: Debounce delays execution until the user stops triggering the event for a set period — the timer resets on each event. Throttle limits execution to at most once per interval, running at a steady rate during continuous events. Use debounce when you need the final value (search input), throttle when you need periodic updates (scroll tracking).

Q:Implement a debounce function from scratch.

A: A debounce function returns a closure that holds a timeoutId. On each call, it clears the previous timeout (clearTimeout) and sets a new one (setTimeout). The inner function only executes after 'delay' ms of silence. Key: the closure preserves the timer reference across calls. Bonus: add a .cancel() method that clears the timeout for cleanup.

Q:Why can't you just create a debounced function inside a React component?

A: Each render creates a new function instance with a new timer. The old timer is abandoned and the new one starts fresh, so the debounce delay never completes. You must memoize the debounced function using useMemo, useRef, or useCallback so the same instance (and its timer) persists across renders.

Q:When would you use debounce vs throttle for a resize handler?

A: It depends on what you're doing. If you need the final window size after the user finishes resizing (e.g., to recalculate a layout once), use debounce. If you need continuous updates during the resize (e.g., to animate elements as the window changes), use throttle. Most resize handlers use debounce because you only care about the final dimensions.

Q:How do you handle race conditions with debounced API calls?

A: Use AbortController. Before each API call, abort the previous in-flight request. Store the AbortController in a ref, call .abort() on it before creating a new one, and pass the new controller's signal to fetch(). This ensures only the latest request's response is processed, even if responses arrive out of order.

Q:What is the 'leading' vs 'trailing' edge in debounce/throttle?

A: Leading edge means the function fires immediately on the first event, then ignores subsequent events during the delay. Trailing edge (default) means the function fires after the delay, once events stop. Lodash's debounce/throttle support both via options: { leading: true, trailing: false }. Leading is useful for button clicks (respond immediately, ignore spam).

Q:How would you debounce a search input while keeping the input responsive?

A: Separate the state update from the side effect. Update the input's state (setQuery) immediately on every keystroke so the user sees what they type. Debounce only the API call (debouncedFetch). This gives instant input feedback while reducing API calls by 90%+. The key insight: debounce the effect, not the UI.

Q:What is requestAnimationFrame throttling and when would you use it?

A: rAF throttling runs a handler once per animation frame (~16ms at 60fps), synced with the browser's paint cycle. It's ideal for visual updates like scroll animations, parallax effects, or drag handlers where you need the smoothest possible rendering. Unlike fixed-interval throttle, rAF adapts to the device's refresh rate.

12

Practice Section

1

The Flickering Search Results

A user reports that search results 'flicker' — they see results for partial queries before the final results appear. The search input calls the API on every keystroke. How would you fix this?

Answer: Add a 300ms debounce to the API call so it only fires after the user stops typing. Combine with AbortController to cancel in-flight requests when a new keystroke arrives. Keep the input state update immediate (no debounce on setQuery) so typing feels responsive.

2

The Frozen Scroll

A developer added a debounced scroll handler (500ms) to update a progress bar. Users complain the progress bar only updates after they stop scrolling, not during. What went wrong?

Answer: Debounce waits for the user to STOP — during continuous scroll, the handler never fires. Replace debounce with throttle (100-200ms) for continuous updates during scroll. Consider requestAnimationFrame throttle for the smoothest visual updates.

3

The Double-Submit Bug

Users are accidentally submitting a payment form twice by double-clicking the submit button. The second click triggers before the first request completes. How would you prevent this?

Answer: Use leading-edge throttle with a 1000ms interval — the first click fires immediately, subsequent clicks are ignored. Also disable the button after the first click and add server-side idempotency as a safety net.

4

The Auto-Save Dilemma

You're building a document editor with auto-save. You want to save after the user stops editing, but also ensure saves happen at least every 30 seconds during long sessions. How would you design this?

Answer: Combine both techniques: use debounce (2000ms) for the primary save that fires after editing pauses, plus a throttle (30000ms) as a safety net ensuring saves happen at least every 30 seconds during continuous typing.

5

The Memory Leak

A React component with a debounced search input is causing memory leaks. The component mounts and unmounts frequently (inside a tab panel). After switching tabs 50 times, the app becomes sluggish. What's happening?

Answer: Each mount creates a new debounced function with a pending timer. When the component unmounts, the timer isn't cancelled — it fires after unmount and tries to setState on an unmounted component. Fix: add useEffect cleanup that calls debouncedFn.cancel() on unmount.

13

Cheat Sheet

Quick Revision Cheat Sheet

Debounce: Delays execution until the user stops triggering the event for a set period. Timer resets on each event. Use for: search input, form validation, auto-save, resize.

Throttle: Limits execution to at most once per interval. Runs at a steady rate during continuous events. Use for: scroll tracking, mouse move, drag handlers, analytics.

Debounce implementation: clearTimeout(id); id = setTimeout(fn, delay). The closure holds the timer reference. Each call cancels the previous timer and starts a new one.

Throttle implementation: if (Date.now() - lastTime >= interval) { lastTime = now; fn(); }. Compare timestamps to decide whether enough time has passed since the last execution.

React trap: Never create debounced/throttled functions inside a component without memoizing. Each render creates a new function with a new timer. Use useMemo, useRef, or useCallback.

Cleanup on unmount: Always cancel pending timers in useEffect cleanup: useEffect(() => () => debouncedFn.cancel(), [debouncedFn]). Prevents setState on unmounted components.

Debounce the effect, not the UI: Update input state immediately (setQuery) for responsive typing. Debounce only the side effect (API call). The user should always see what they're typing.

Leading vs trailing edge: Leading: fires immediately on first event, ignores rest. Trailing (default): fires after delay once events stop. Lodash supports both via options.

AbortController: Combine with debounce to cancel stale API requests. Call abort() before each new fetch. Prevents race conditions where old responses overwrite newer ones.

rAF throttle: For visual updates, throttle to requestAnimationFrame (~16ms at 60fps). Syncs with the browser's paint cycle for the smoothest possible rendering.

Choosing the right delay: Search: 200-500ms debounce. Scroll: 100-200ms throttle. Auto-save: 1-2s debounce. Mouse move: 50-100ms throttle. Button click: 500-1000ms throttle.

The one-line difference: Debounce: 'Run after the user stops.' Throttle: 'Run while the user is doing it, but not too often.' If you need the final value → debounce. If you need continuous updates → throttle.