Debouncing & Throttling

Reviewed & published by Brayan K

Modern JavaScript applications rely heavily on real-time interactions—scrolling, resizing, typing, mouse movement, search bars, touch gestures, infinite feeds, and dynamic UI. These events can fire dozens to hundreds of times per second, and without control, they can destroy performance, cause UI lag, freeze the browser, and produce unnecessary network calls.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:

What You'll Learn in This Lesson

Two core techniques every advanced engineer must master are debouncing and throttling.

Debouncing and throttling are performance-optimization patterns that allow you to delay, limit, or batch high-frequency events so that your code runs efficiently, predictably, and without overloading the CPU. These patterns are used in nearly every major global app—YouTube search, Instagram infinite scroll, Google Maps dragging, Stripe dashboards, and all modern UIs.

🔥 Why High-Frequency Events Crush Performance

Events that fire extremely fast include:

A typical scroll event can fire 60–120 times per second, depending on hardware.

If each event triggers heavy code—like rendering, API calls, DOM updates, or React state changes—your app lags or freezes.

Example of a bad scroll handler:

This could call heavyFunction() hundreds of times in a single second.

⚡ Debounce: "Wait until the user stops doing something"

Debouncing ensures that a function only runs after the event stops firing for a certain delay.

User types p y t h o n

✨ Example: Debounced search input

Worked example: watch debounce swallow five keystrokes

That description is easy to nod along to and hard to believe until you see the timeline. The problem is that a real setTimeout fires after the editor has already captured the console, so the one call you most want to see never prints.

The fix is a fake clock: a variable holding "the time", a list of pending timers, and an advance(ms) function that moves the clock forward and runs whatever became due. Now every step prints in order, and the timestamps are exact rather than approximate. Read the comments, then run it.

// ---- A fake clock, so you can SEE the timing -------------------------
// Real debounce uses setTimeout. This editor captures the console the moment
// your code finishes, so a callback that fires 500ms later would never show up.
// So you drive a tiny scheduler by hand instead and print every step.

let now = 0;          // the 'current time', in milliseconds
let jobs = [];        // timers waiting to run: { id, at, fn }
let nextId = 1;

function fakeSetTimeout(fn, delay) {
  const id = nextId++;
  jobs.push({ id: id, at: now + delay, fn: fn });  // remember WHEN it should run
  return id;                                       // the handle used to cancel it
}

function fakeClearTimeout(id) {
  jobs = jobs.filter(job => job.id !== id);        // drop that timer, unfired
}

function advance(ms) {                             // move the clock forward
  const target = now + ms;
  jobs.sort((a, b) => a.at - b.at);                // soonest first
  while (jobs.length > 0 && jobs[0].at <= target) {
    const job = jobs.shift();
    now = job.at;                                  // clock reaches the due time...
    job.fn();                                      // ...and the callback runs
  }
  now = target;
}

// ---- Debounce, built on that clock -----------------------------------
function debounce(fn, delay) {
  let timerId = null;                 // lives in the closure, survives every call

  return function (...args) {
    if (timerId !== null) fakeClearTimeout(timerId);    // cancel the PREVIOUS plan
    timerId = fakeSetTimeout(() => fn(...args), delay); // and start the wait again
  };
}

let apiCalls = 0;
const search = debounce(function (query) {
  apiCalls++;
  console.log('[t=' + now + 'ms] 🌐 API call for ' + query);
}, 500);

// ---- Simulate a user typing 'python', one key every 120ms ------------
const keystrokes = ['p', 'py', 'pyt', 'pyth', 'pytho', 'python'];
for (const value of keystrokes) {
  console.log('[t=' + now + 'ms] keystroke -> ' + value);
  search(value);   // each keystroke kills the old timer and starts a new 500ms one
  advance(120);    // 120ms passes before the next key
}

console.log('[t=' + now + 'ms] user stops typing');
advance(600);      // nothing interrupts the timer now, so it finally fires
console.log('[t=' + now + 'ms] total API calls: ' + apiCalls + ' (not 6)');

// ✅ Expected output:
// [t=0ms] keystroke -> p
// [t=120ms] keystroke -> py
// [t=240ms] keystroke -> pyt
// [t=360ms] keystroke -> pyth
// [t=480ms] keystroke -> pytho
// [t=600ms] keystroke -> python
// [t=720ms] user stops typing
// [t=1100ms] 🌐 API call for python
// [t=1320ms] total API calls: 1 (not 6)
//
// Read the two key lines: the last keystroke lands at 600ms, and the call
// fires at 1100ms — exactly 500ms after the typing stopped, not 500ms after
// it started. Change 500 to 100 and the API call moves to t=700ms.

🎯 Your turn: debounce an autosave

Same clock, different job. A note editor should save the draft two seconds after the user stops typing — not once per keypress. Everything is written for you except the two lines that are debounce: cancelling the old timer, and starting a new one.

// 🎯 YOUR TURN — fill in the blanks marked with ___
// The fake clock is already written. Only debounce() is missing.

let now = 0;
let jobs = [];
let nextId = 1;
function fakeSetTimeout(fn, delay) {
  const id = nextId++;
  jobs.push({ id: id, at: now + delay, fn: fn });
  return id;
}
function fakeClearTimeout(id) {
  jobs = jobs.filter(job => job.id !== id);
}
function advance(ms) {
  const target = now + ms;
  jobs.sort((a, b) => a.at - b.at);
  while (jobs.length > 0 && jobs[0].at <= target) {
    const job = jobs.shift();
    now = job.at;
    job.fn();
  }
  now = target;
}

function debounce(fn, delay) {
  let timerId = null;         // the closure that remembers the pending timer

  return function (...args) {
    // 1) A newer call arrived, so throw away the plan you already made
    if (timerId !== null) ___;    // 👉 replace ___ with  fakeClearTimeout(timerId)

    // 2) Start the wait again from zero
    timerId = ___;                // 👉 replace ___ with
                                  //    fakeSetTimeout(() => fn(...args), delay)
  };
}

let saves = 0;
const autosave = debounce(function (text) {
  saves++;
  console.log('[t=' + now + 'ms] 💾 saved draft: ' + text);
}, 2000);                        // save 2 seconds after typing stops

const edits = ['Dear', 'Dear Sam', 'Dear Sam,', 'Dear Sam, thanks', 'Dear Sam, thanks!'];
for (const text of edits) {
  console.log('[t=' + now + 'ms] typing: ' + text);
  autosave(text);
  advance(400);                  // the user types every 400ms
}
console.log('[t=' + now + 'ms] typing stopped');
advance(3000);                   // sit idle and let the timer mature
console.log('Saves: ' + saves + ' (5 edits, 1 save)');

// ✅ Expected output once both blanks are filled:
// [t=0ms] typing: Dear
// [t=400ms] typing: Dear Sam
// [t=800ms] typing: Dear Sam,
// [t=1200ms] typing: Dear Sam, thanks
// [t=1600ms] typing: Dear Sam, thanks!
// [t=2000ms] typing stopped
// [t=3600ms] 💾 saved draft: Dear Sam, thanks!
// Saves: 1 (5 edits, 1 save)
//
// Getting 'Saves: 5' with a save after every edit? Blank 1 is not cancelling.
// Getting no save line at all? Blank 2 never scheduled anything.

🚀 Throttle: "Allow the function to run, but only every X milliseconds"

Throttling ensures a function runs at a consistent interval, no matter how many times the event fires.

scroll fires 100x per second → your code runs 100x per second

Worked example: 31 scroll events, 5 redraws

Throttle needs no timer at all — just a memory of when the function last ran. Each event asks one question: has delay milliseconds passed since then? If yes it runs and updates that memory; if no it is dropped on the floor.

Below, a scroll event arrives every 16ms (that is 60 per second, the rate a smooth-scrolling browser fires at) for half a second. A 100ms throttle should let roughly one in six through. Run it and count.

// 'now' is a fake clock again — the loop below advances it by hand so the
// whole half-second of scrolling prints at once.
let now = 0;

function throttle(fn, delay) {
  let last = -Infinity;               // -Infinity means 'never run yet', so the
                                      // very first call always gets through

  return function (...args) {
    if (now - last >= delay) {        // has the window elapsed?
      last = now;                     // remember when this run happened
      fn(...args);                    // yes - let it through
    }
    // no else branch: extra events are simply dropped, never queued
  };
}

let updates = 0;
const onScroll = throttle(function (y) {
  updates++;
  console.log('[t=' + now + 'ms] progress bar redrawn at y=' + y);
}, 100);                              // at most one redraw per 100ms

// A real scroll fires about every 16ms (60 frames per second)
let fired = 0;
for (let i = 0; i < 31; i++) {
  fired++;
  onScroll(i * 40);   // the page has scrolled another 40 pixels
  now += 16;          // 16ms later, the next scroll event arrives
}

console.log('scroll events fired: ' + fired);
console.log('handler actually ran: ' + updates + ' times');

// ✅ Expected output:
// [t=0ms] progress bar redrawn at y=0
// [t=112ms] progress bar redrawn at y=280
// [t=224ms] progress bar redrawn at y=560
// [t=336ms] progress bar redrawn at y=840
// [t=448ms] progress bar redrawn at y=1120
// scroll events fired: 31
// handler actually ran: 5 times
//
// Notice the gaps are 112ms, not exactly 100ms. Throttle does not schedule
// anything — it waits for the next event that happens to arrive after the
// window closed, and events only arrive on 16ms boundaries.

🎯 Deep Technical Difference

Debounce:

Throttle:

Both solve overload, but serve totally different UX purposes.

🧠 Mistakes Developers Commonly Make

❌ Putting debounce inside event handler

This recreates the function every time.

Debouncing creates laggy, delayed animations. Use throttle.

Dangerous! A throttled API might send multiple requests when the user didn't intend to.

❌ Forgetting to pass through arguments

Many poorly written debounce/throttle functions drop parameters.

🔥 Why Debounce Uses Closures + Timers

Every debounce implementation follows the same pattern:

Let's rewrite debounce in a clearer annotated form:

Why debounce relies on closures:

Debounce is stateful event handling, powered by closures.

🧱 Why Throttle Uses Timestamps Instead of Timers

Throttle logic is fundamentally different—it guarantees execution at most once per time window.

Throttle focuses on time intervals instead of inactivity.

🚀 Combining Debounce + Throttle for Hybrid Control

Some events require the precision of debounce and the steady pacing of throttle.

Real example: A UI with live suggestions while typing:

This hybrid approach is used by Gmail, Notion, YouTube Search, Spotify Search, and most high-performance dashboards.

Practical use case example:

This gives the user rapid UI updates while avoiding expensive network spam.

⚡ Designing Ultra-Smooth Scroll Performance

Modern web apps run multiple scroll tasks:

If each fires at full frequency, the UI becomes laggy.

Advanced example when tasks differ:

This dual-structure is exactly how apps like Facebook, Instagram and Apple's website optimize performance.

🧠 When to Use Which Technique

✔️ Use Debounce When:

✔️ Use Throttle When:

✔️ Use Both When:

🎯 Mini-Challenge: write throttle from scratch

No blanks this time — the whole function is yours. A dashboard receives a price tick every 50ms from a websocket, but the chart should only redraw four times a second (once every 250ms). Write throttle using nothing but the fake clock and a variable that remembers the last run.

The simulation and the expected output are given, so you can tell immediately whether you have it right.

let now = 0;   // the fake clock, advanced by the simulation below

// 🎯 MINI-CHALLENGE: write throttle(fn, delay)
// 1. Before returning, declare a variable 'last' holding the clock time of the
//    previous run. Start it at -Infinity so the very first call always runs.
// 2. Return a function that takes ...args and asks: now - last >= delay?
// 3. If yes: set last = now, then call fn(...args).
// 4. If no: do nothing at all (throttle drops events, it does not queue them).
function throttle(fn, delay) {
  // your code here
}

let redraws = 0;
const updateChart = throttle(function (price) {
  redraws++;
  console.log('[t=' + now + 'ms] chart redrawn, price ' + price);
}, 250);                       // four redraws per second, maximum

// A price tick arrives every 50ms for two seconds
let ticks = 0;
for (let i = 0; i < 40; i++) {
  ticks++;
  updateChart(100 + i);        // the price creeps up by 1 each tick
  now += 50;
}

console.log('ticks received: ' + ticks);
console.log('chart redraws:  ' + redraws);

// ✅ Expected output when your throttle is right:
// [t=0ms] chart redrawn, price 100
// [t=250ms] chart redrawn, price 105
// [t=500ms] chart redrawn, price 110
// [t=750ms] chart redrawn, price 115
// [t=1000ms] chart redrawn, price 120
// [t=1250ms] chart redrawn, price 125
// [t=1500ms] chart redrawn, price 130
// [t=1750ms] chart redrawn, price 135
// ticks received: 40
// chart redraws:  8
//
// 40 redraws instead of 8 means the time check is missing.
// ❌ TypeError: updateChart is not a function means throttle() returned
// nothing — it must RETURN the wrapper function, not just define it.

🧪 Practice Challenges

Challenge 1 — Debounced Search Bar

Create a search bar that only fires API requests after typing stops for 300ms.

Challenge 2 — Throttled Scroll Progress

Build a throttled scroll listener that updates a "reading progress" bar at 60 FPS.

Challenge 3 — Debounced Window Resize

Create a debounced window resize handler that recalculates layout after resizing stops.

Challenge 4 — Throttled Mouse Movement

Throttle a mousemove event to display cursor coordinates smoothly without overwhelming the CPU.

Challenge 5 — Build Your Own Utilities

Create your own debounce and throttle utilities and attach them to window.Utils.

Interactive Code Editor

Try out debounce and throttle patterns. The editor below has working examples you can modify and test:

🏁 Final Summary

Debouncing and throttling are two of the most valuable performance patterns in advanced JavaScript. They transform sluggish, heavy UIs into fast, responsive, polished experiences. Every modern frontend—from React to Vue to pure JavaScript—relies on these techniques to control high-frequency events and prevent lag. Mastering these will give you the same optimization skills used by top-tier engineers at Google, Meta, Amazon, and high-performance SaaS platforms.

Key Takeaways:

📋 Quick Reference

ConceptHow it works
debounce(fn, 300)Runs fn only after 300ms of inactivity
throttle(fn, 100)Runs fn at most once every 100ms
clearTimeoutKey to debounce — cancels previous timer
Date.now()Key to throttle — compares timestamps
Use debounce forSearch bars, form validation, auto-save
Use throttle forScroll, resize, mouse movement, animations

Lesson Complete!

You've mastered debouncing and throttling — two of the most powerful performance patterns in JavaScript. You can now build UIs that stay smooth and responsive under heavy event loads.

Practice quiz

What does debouncing do?

  • Runs a function on every event
  • Runs a function at most once per interval
  • Runs a function only after the event stops firing for a delay
  • Cancels the function entirely

Answer: Runs a function only after the event stops firing for a delay. Debouncing waits for inactivity and runs the function only after the events stop for the chosen delay.

What does throttling do?

  • Runs a function at most once every X milliseconds
  • Runs a function only after events stop
  • Runs a function on every single event
  • Delays the first call forever

Answer: Runs a function at most once every X milliseconds. Throttling lets the function run at most once per time window no matter how often the event fires.

If a user types 'python' with a 500ms debounce on the search input, how many API requests fire?

  • 6
  • 5
  • 0
  • 1

Answer: 1. Each keystroke resets the timer, so only the final settled value triggers a single request.

Which technique is best for a search bar / autocomplete?

  • Throttle
  • Debounce
  • Neither
  • Both always

Answer: Debounce. Debounce waits until the user stops typing, ideal for search bars and form validation.

Which technique is best for scroll animations and infinite scroll?

  • Throttle
  • Debounce
  • Neither
  • Plain event handler

Answer: Throttle. Throttle gives steady, predictable updates, perfect for scroll-based animations.

What does a debounce implementation use to cancel the previous scheduled call?

  • Date.now()
  • Promise.race
  • clearTimeout on the stored timer
  • requestAnimationFrame

Answer: clearTimeout on the stored timer. Debounce stores a timer in its closure and calls clearTimeout to cancel the previous schedule on each event.

What does throttle typically use to decide whether to run?

  • A stored timer cleared each time
  • A timestamp compared with Date.now()
  • A counter of events
  • A Set of pending calls

Answer: A timestamp compared with Date.now(). Throttle compares the current Date.now() against the last execution time to enforce the interval.

Why does debounce rely on closures?

  • To make it run faster
  • To avoid using setTimeout
  • To preserve the return value
  • To persist the timeoutId across event invocations so it can be cancelled

Answer: To persist the timeoutId across event invocations so it can be cancelled. The closure keeps timeoutId alive between calls; without it the timer could not be cancelled.

Why does debounce fail for a continuous scroll handler?

  • Scroll never fires
  • Scroll events never really 'stop', so debounce would never fire during scrolling
  • Debounce is too fast
  • Scroll is not an event

Answer: Scroll events never really 'stop', so debounce would never fire during scrolling. Because scrolling keeps firing, the debounce timer keeps resetting and never runs; throttle gives steady updates instead.

Why use fn.apply(this, args) inside debounce/throttle wrappers?

  • To make them async
  • To clone the function
  • To preserve the original this context and pass through the arguments
  • To delay execution

Answer: To preserve the original this context and pass through the arguments. Using fn.apply(this, args) forwards both the calling context and the arguments so they are not dropped.

Continue this course