loading bytebasex.com
Latest

Debouncing and Throttling in JavaScript Explained

Learn the difference between debouncing and throttling in JavaScript, build both from scratch, and know when to use each.

You build a search box that fetches results as the user types. It works in your first quick test. Then you actually use it properly, typing a full sentence at normal speed, and your network tab lights up with a request for every single keystroke: one for "h", another for "he", another for "hel", and so on. Your server is getting hammered with requests that are almost immediately obsolete, since only the final, complete query actually matters.

Or maybe it's not typing, it's scrolling. A scroll event handler can fire dozens of times per second, and if that handler does anything remotely expensive, the whole page starts to feel laggy and unresponsive. Debouncing and throttling are the two standard techniques for controlling exactly this kind of event flood, and they solve genuinely different versions of the problem, which is why mixing them up is such a common mistake.

Debouncing and Throttling in JavaScript Explained

Welcome to BytebaseX. I'm Suptojit Modak, and in this guide, I'll break down exactly what each technique does, build both from scratch so you can see precisely what's happening rather than just importing a library blindly, and go through which one actually fits which real situation.

The Core Difference, in One Sentence Each

Debouncing waits for a pause in activity before running the function, and resets that wait every time a new event comes in. Throttling runs the function at most once every set interval, no matter how many events fire in between.

Debounce says: "wait until they stop." Throttle says: "run me regularly, but not too often." Same goal, event-flood control, completely different behavior underneath.

Debouncing, Built From Scratch

Here's a plain, dependency-free debounce implementation:

function debounce(func, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

Walk through what actually happens: every time the returned function is called, it immediately cancels any previously scheduled call with clearTimeout, then schedules a new one. If calls keep coming in faster than the delay, the scheduled execution keeps getting pushed back and never actually fires. Only once there's a genuine pause, longer than delay, does the function finally run.

Applied to the search box problem from the intro:

const handleSearch = debounce((query) => {
  fetchSearchResults(query);
}, 300);

searchInput.addEventListener("input", (e) => handleSearch(e.target.value));

Now, typing a full sentence at normal speed only triggers one actual search request, fired 300 milliseconds after the last keystroke, instead of one request per character.

Throttling, Built From Scratch

Throttling looks structurally similar but behaves differently:

function throttle(func, interval) {
  let isWaiting = false;
  return function (...args) {
    if (!isWaiting) {
      func.apply(this, args);
      isWaiting = true;
      setTimeout(() => {
        isWaiting = false;
      }, interval);
    }
  };
}

Here, the first call runs immediately, then a flag locks out any further calls until interval milliseconds have passed. Unlike debounce, calls during the "locked" period aren't rescheduled, they're simply ignored. Applied to a scroll handler:

const handleScroll = throttle(() => {
  updateProgressBar();
}, 200);

window.addEventListener("scroll", handleScroll);

Even if the scroll event fires 60 times per second, updateProgressBar only actually runs about 5 times per second (once every 200ms), keeping the visual updates smooth without running expensive logic on every single scroll tick.

A Live Demo: Watching the Difference

Descriptions only get you so far here. Move your mouse rapidly inside the box below and watch how each counter behaves differently.

Move your mouse around rapidly inside this box

No optimization

0

Throttled (200ms)

0

Debounced (300ms)

0

Notice: the debounced count barely moves while you're actively moving, then jumps once you stop.

When to Use Which: Real Situations

SituationTechniqueWhy
Search-as-you-typeDebounceOnly the final query, once typing pauses, actually matters
Autosaving a form draftDebounceWait until the user stops editing before saving
Scroll-triggered animations or progress barsThrottleNeeds regular updates during continuous scrolling, not just at the end
Window resize recalculating layoutThrottleShould respond continuously while dragging, not only once resizing stops
Button click that triggers an expensive actionDebouncePrevents duplicate submissions from rapid double-clicks
Mouse-move-based drag interactionsThrottleNeeds frequent, regular position updates to feel responsive

The pattern worth internalizing: debounce fits situations where only the final state matters and intermediate states are noise. Throttle fits situations where you need ongoing feedback at a controlled, regular rate, not just a final result.

Combining Debounce With Async Operations

The search box example is a debounce wrapped around an async fetch call, and this combination is common enough to be worth calling out specifically, along with a mistake it invites. If you already know how Promises and async/await work, notice that the debounced function itself doesn't need to be async; it's just scheduling when the async function inside it eventually runs:

const handleSearch = debounce(async (query) => {
  const results = await fetchSearchResults(query);
  renderResults(results);
}, 300);
Warning: A real risk with debounced async calls: if a user types, pauses long enough to trigger a search, then types more and pauses again before the first request finishes, you can end up with two requests in flight, and there's no guarantee the slower, older one resolves first. If it does, it can overwrite the results of the newer, more relevant search with stale data. Guard against this by tracking a request ID or timestamp, and only rendering results if they belong to the most recent request fired.

Common Mistakes

Using Debounce When You Needed Throttle

Apply debounce to a scroll-triggered progress bar, and the bar won't update at all while scrolling is continuous, it'll only jump to its final position once scrolling actually stops. That's rarely the intended effect for something meant to visually track ongoing scroll position.

Creating a New Debounced Function on Every Render

This one is specific to frameworks like React. If you create your debounced function inside a component body without memoizing it, a new debounce closure gets created on every re-render, which resets its internal timer state and effectively breaks the debouncing entirely:

// Wrong: recreates the debounced function every render, breaking it
function SearchBox() {
  const handleSearch = debounce((q) => fetchResults(q), 300);
  // ...
}

// Right: create it once and keep the same reference across renders
function SearchBox() {
  const handleSearch = useMemo(() => debounce((q) => fetchResults(q), 300), []);
  // ...
}

Forgetting to Clean Up Pending Timers

If a component unmounts while a debounce timer is still pending, that scheduled function can still fire afterward, sometimes trying to update state on a component that no longer exists. Clear the pending timeout when the component unmounts, or check whether the component is still mounted before acting on the result.

Should You Just Use a Library Instead?

For production code, reaching for a well-tested implementation, like debounce and throttle from Lodash, is a completely reasonable choice, and honestly the more common real-world approach. Library implementations handle a handful of edge cases (like leading vs trailing edge execution, and cancel methods) that the simplified versions in this guide don't cover.

That said, building both from scratch once, the way we did above, is genuinely worth doing at least one time. It turns "some magic function that controls event frequency" into something you can actually picture happening: a timer being set, cleared, and reset, line by line. That mental model makes debugging a misbehaving debounce or throttle, wherever it comes from, considerably easier.

Quick Reference

QuestionAnswer
Only the final call after a pause matters?Debounce
Need regular updates throughout continuous activity?Throttle
Search input, autosave, resize-end handlingDebounce
Scroll position tracking, drag interactions, rate-limited API pollingThrottle
Need to cancel a pending debounced call?Store the timeout ID and clearTimeout it manually, or use a library's built-in .cancel()

Frequently Asked Questions

Does debounce ever run the function immediately, on the very first call?

The basic version covered in this guide always waits for the full delay before running, even on the first call, which is usually called "trailing edge" debouncing. Some implementations, including Lodash's, support a "leading edge" option that runs immediately on the first call and then ignores subsequent calls until the pause completes, which fits a different set of use cases like preventing duplicate button submissions.

What delay value should I actually use?

There's no universal number; it depends on the interaction. Search-as-you-type commonly uses 250 to 400 milliseconds, long enough to catch a natural typing pause but short enough to still feel responsive. Scroll-based throttling often uses 100 to 200 milliseconds. The right value is really whatever feels responsive in manual testing for your specific interaction.

Can I debounce and throttle the same event at once?

It's technically possible to layer them, but it's rarely needed and adds real complexity for a marginal benefit in most cases. It's usually clearer to identify which behavior you actually want, immediate regular updates or waiting for a pause, and apply just the one technique that matches.

Does throttling guarantee the function runs exactly once per interval?

It guarantees at most once per interval, not exactly once. If no events fire during a given interval, the function simply doesn't run during that window at all. It only runs when there's actually an event to respond to, just rate-limited to that maximum frequency.

Is CSS scroll-based animation a better alternative to a throttled scroll handler?

For pure visual effects tied to scroll position, modern CSS scroll-driven animations can avoid JavaScript entirely and often perform better, since they run off the main thread. Throttling is still the right tool when you need actual JavaScript logic to run, like fetching more content near the bottom of a page, not just a visual effect.

Conclusion

Debounce waits for the noise to stop before doing anything. Throttle keeps doing something at a steady, controlled pace regardless of the noise. Once you can name which behavior a given situation actually needs, picking the right one stops being a guessing game, and event-heavy interactions like search boxes, scroll handlers, and resize listeners stop being a performance liability.

About the author

Suptojit Modak
Suptojit Modak
I'm Suptojit Modak, a web developer and the person behind BytebaseX — a blog with tutorials, guides, and free resources for developers and bloggers, built to be simple and easy to follow.

Instagram · GitHub · Facebook

Post a Comment