Puneet Sharma - Frontend Developer & UI Engineer
Puneet Sharma
Frontend Dev & UI Engineer · 16+ yrs · pixel-perfect HTML, React & WordPress

JavaScript Debounce vs Throttle: When Should You Use Each?

Learn debounce vs throttle in JavaScript with 5 live demos — search, scroll, autosave, and a side-by-side comparison showing when to use each.
JavaScript Debounce vs Throttle: When Should You Use Each?

Both exist to solve the same underlying problem — a browser event that fires far more often than your code actually needs to react to it. A user typing into a search box fires an input event on every keystroke. A user scrolling or dragging fires dozens of events a second. Running an API call, a layout recalculation, or a re-render on every single one of those is wasteful at best and janky at worst. Debounce and throttle both slow that flood down — the real question isn't "which one is more efficient," it's what shape of behavior you actually want: debounce says "wait until things go quiet, then do it once." Throttle says "keep doing it, but never more often than this." Once that distinction clicks, most "which one do I reach for" questions answer themselves.

The 5 demos below are all real, running JavaScript — type, scroll, move your mouse — so you can watch each technique's actual firing pattern instead of reading about it secondhand. One demo in particular puts both side by side on the exact same stream of events, so the difference isn't a claim, it's something you can watch happen in real time.

Every embed also has a Fork & Edit button that opens the exact snippet, already running, in My Code — FWD Tools' free in-browser editor. Change a delay from 300ms to 1000ms on a real debounce and watch what changes — that's a faster way to build real intuition than any explanation.

The one-sentence rule

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);               // every call cancels the last wait
    timer = setTimeout(() => fn(...args), delay);
  };
}

function throttle(fn, limit) {
  let inThrottle = false;
  return (...args) => {
    if (!inThrottle) {
      fn(...args);                     // runs immediately, then goes quiet
      inThrottle = true;
      setTimeout(() => (inThrottle = false), limit);
    }
  };
}

Debounce resets a timer on every call and only ever runs your function once that timer finally survives uninterrupted — so a burst of 50 calls in a row collapses into exactly 1, and it fires after the burst ends. Throttle runs your function immediately on the first call, then ignores every call that follows until a fixed amount of time has passed — so the same burst of 50 calls produces several evenly-spaced runs during the burst, not one run after it. Same goal (fewer runs), opposite shape (fires at the end vs. fires steadily throughout).

1. Debounced Search Box

Type in the box below. With debounce on, a fake "API call" fires only once you pause typing for 500ms — turn it off and a call fires on every keystroke instead.

The rule:

const debouncedSearch = debounce(query => fetchResults(query), 500);

input.addEventListener('input', e => debouncedSearch(e.target.value));

This is the single most common reason debounce exists. Without it, typing "javascript" fires ten separate network requests — one per keystroke — and nine of them are wasted work for a result the user never even saw, because it was replaced by the next keystroke's response a moment later. With debounce, every keystroke still resets the 500ms timer, but only the last keystroke in a typing burst ever survives long enough to actually trigger a call. The counters make this concrete: type "hello world" at a normal pace and you'll see 11 keystrokes but exactly 1 API call.

Best for: search-as-you-type, form field validation that hits an API, autocomplete, any "wait for the user to finish" input. Tip: 300–500ms is a good default delay for typing — short enough that the result still feels responsive, long enough to skip almost every intermediate keystroke.

2. Throttled Scroll Updates

Scroll inside the box below. Raw scroll events fire dozens of times a second — the throttled counter updates at most once every 150ms no matter how fast you scroll.

The rule:

const throttledOnScroll = throttle(() => updateProgressBar(), 150);

scrollBox.addEventListener('scroll', throttledOnScroll);

Unlike search, a scroll handler can't just wait for scrolling to stop — a progress bar or a scroll-spy nav needs to keep up while the user is actively scrolling, not sit frozen until they finish. That's exactly the case throttle is built for: the raw scroll event might fire 40+ times a second, but the throttled handler runs at a fixed, predictable rate regardless, capping the work without ever going silent for the whole gesture the way debounce would.

Best for: scroll position tracking, scroll-spy navigation highlighting, infinite-scroll "near the bottom" checks, any handler that needs to stay live throughout continuous activity rather than only at the end. Tip: 100–200ms is plenty for anything visual — the human eye can't tell a progress bar updating every 150ms from one updating on every raw event, but the CPU very much can.

3. Debounce vs Throttle, Side by Side

Move your mouse (or finger) around inside the box below and keep it moving. Watch how each track fills — that's the entire difference between the two techniques, made visible.

The rule:

const throttled = throttle(() => tick('throttle'), 150);
const debounced = debounce(() => tick('debounce'), 300);

zone.addEventListener('mousemove', () => {
  tick('raw');   // every event, no wrapper
  throttled();   // fires steadily, capped at once per 150ms
  debounced();   // fires once, only after movement stops for 300ms
});

This is the demo that makes the distinction impossible to un-see. While you're actively moving the mouse, the raw track fills up almost solid — every single mousemove gets a tick. The throttled track fills at a steady, even pace the whole time you're moving — a tick roughly every 150ms, no matter how fast you wave the mouse around. The debounced track stays completely empty while you keep moving, and only gets a single tick once you stop and hold still for 300ms. That's not a bug — it's the entire point: debounce's timer keeps getting reset by every new event, so continuous movement means it never gets the uninterrupted gap it needs to fire, until you finally stop.

Best for: building real intuition, fast, before reaching for either in actual code. Tip: if you've ever wondered why a debounced handler "never seems to fire" during continuous activity, this demo is why — that's expected behavior, not a bug in your implementation.

4. Debounced Autosave

Type in the box below. The status goes to "Typing…" instantly on every keystroke, but the note is only "Saved" once you pause for 800ms — nothing saves on every keystroke.

The rule:

const debouncedSave = debounce(text => saveDraft(text), 800);

textarea.addEventListener('input', e => {
  setStatus('typing');            // runs on every keystroke, immediately
  debouncedSave(e.target.value);  // only actually saves after a pause
});

Autosave is a good example of combining a debounced action with UI feedback that isn't debounced. The "Typing…" status updates on every single keystroke — that part should feel instant. But the actual save (the expensive, or rate-limited, or network-bound part) only runs through the debounced function, so hammering out a paragraph doesn't trigger a save request per character. The 350ms artificial delay inside doSave before the status flips to "Saved" is there deliberately, to make the save itself visibly distinct from the debounce wait that preceded it — in a real app that gap is your actual network round-trip.

Best for: autosave, draft persistence, any "save this eventually, but not on every change" pattern. Tip: keep instant feedback (like the "Typing…" label here) outside the debounced function — debounce the expensive work, not the parts of the UI that should react immediately.

5. Which One Should You Use?

Click a real scenario below to see whether debounce or throttle actually fits it, and why.

The rule:

// Debounce: "do this once, after things go quiet"
search, form validation, autosave, resize-then-recalculate-once

// Throttle: "do this steadily, no more than every N ms"
scroll tracking, drag updates, live resize recalculation, rate-limited clicks

The six scenarios in this demo aren't arbitrary — they're the cases that come up constantly in real UI work, and each one has a genuinely correct answer once you ask "does this need to fire once after activity stops, or steadily while activity continues?" Search and autosave both want silence first (debounce). Scroll-spy and drag both want to stay live the whole time (throttle). "Prevent double form submit" is a debounce case for a subtler reason: you want a burst of rapid clicks to collapse into exactly one action, and debounce's "only the last one survives" behavior does exactly that.

Best for: a quick gut-check on any handler where you're genuinely unsure which technique fits. Tip: when a scenario could plausibly go either way, ask whether the handler needs to update during the activity (throttle) or only needs the final state once it's done (debounce) — that one question resolves almost every edge case.

Choosing between them, in practice

  • Use debounce when you only care about the final state, after activity stops. Search-as-you-type, form validation, autosave, "recalculate once resizing has finished" — the intermediate values during the burst are noise you want to skip entirely.
  • Use throttle when the handler needs to stay live throughout continuous activity. Scroll tracking, drag position, live resize recalculation — going silent until activity stops (what debounce does) would make the UI feel frozen or laggy while it's actually happening.
  • Debounce fires at the end; throttle fires (at a steady rate) throughout. Demo #3 makes this the one fact worth remembering above all the others — if you only watch one demo on this page, watch that one.
  • Both exist to reduce how often expensive work runs — neither changes what that work does. Wrapping a function in debounce() or throttle() doesn't touch its logic at all; it only changes how often the browser is allowed to call it.
  • When genuinely unsure, ask "does the user need to see updates while this is happening, or only once it's done?" "While it's happening" points to throttle. "Once it's done" points to debounce.

Frequently asked questions

What's the actual difference between debounce and throttle, in one sentence?

Debounce waits for activity to pause, then runs your function once. Throttle runs your function immediately and repeatedly, but never more often than a fixed interval, for as long as activity continues.

Why did my debounced function never seem to fire?

This is almost always expected behavior, not a bug — demo #3 shows it directly. A debounced function's internal timer resets on every single call, so if the triggering event keeps firing continuously (a user moving a mouse without stopping, for example), the function never gets the uninterrupted gap of silence it needs to actually run. It will fire the instant the activity stops.

Can I use throttle for a search box instead of debounce?

You could, but it usually fights the goal. Throttle would fire a request partway through typing a word — using a stale, incomplete query — in addition to whatever final request debounce alone would have sent. Debounce is specifically the "only the final, complete value matters" tool, which is exactly what a search query is.

Does lodash's debounce/throttle do anything my own doesn't?

The core mechanism is the same as the two functions at the top of this post. Lodash's versions add configurable leading/trailing-edge control (fire on the first call, the last call, or both), a maxWait option for debounce, and a .cancel()/.flush() API for manually canceling or forcing a pending call — genuinely useful in larger apps, but not required to understand or use either technique correctly.

What delay/limit value should I actually use?

For debounce on typing, 300–500ms feels responsive without firing on every keystroke. For throttle on scroll or drag, 100–200ms is usually indistinguishable from unthrottled to a human eye while cutting the actual call count drastically. Both are starting points, not rules — the right number depends on how expensive the wrapped function actually is.

Do I need a library to use debounce or throttle?

No — both functions at the top of this post are complete, dependency-free implementations, and every demo on this page runs on exactly those two functions with nothing else loaded. A library like lodash is worth reaching for once you want the extra options mentioned above, not because the core technique is hard to implement yourself.

Every demo above ships its full HTML, CSS and JS in the HTML / CSS / JS tabs in its own top bar. The fastest way to actually internalize the difference is to click Fork & Edit on demo #3, change the throttle limit from 150ms to 600ms, and watch how much the throttled track's pacing changes. Once you've got a version you like, save it to My Code and it's yours to keep, tweak further, or reuse in a real project.

Want to go deeper than a single embed? The JavaScript Playground is FWD Tools' full in-browser JS sandbox — a real editor with instant preview, built for exactly this kind of edit-and-see learning, without the five-tab constraint of a blog post.

Recommended next step: open the JavaScript Playground and rebuild one of the demos above from scratch — writing your own debounce() or throttle() wrapper by hand, with a live preview right next to it, is what actually makes the pattern stick.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer and the creator of FWD Tools and WebDevPuneet. Follow him on X/Twitter

Post a Comment