JavaScript and TypeScript course Β· Module 8: Events and the DOM

Throttling and Debouncing

4 min read Β·

Sometimes events occur too frequently (e.g., scroll, resize, mousemove). We use throttling and debouncing techniques to limit the frequency of function execution.

Take the species search box in the control center. Every character typed into the field sends an input event, so the word "Velociraptor" means twelve queries to the lab database, even though only the last one matters. Scrolling the page can report scroll many times per second. "Not every rustle in the bushes calls for an alarm," says Ray Arnold. "You have to know when to react."

Implementing Optimization Techniques

Both techniques are functions that take your function and return a more careful version of it. Let's start with debouncing, which waits for silence like a raptor crouching in the grass: it strikes only once the prey stops moving.

// Debouncing - executes the function only after a specified time without new calls
function debounce(func, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func.apply(this, args), delay);
  };
}

The timeoutId variable lives in a closure, so it remembers the timer between calls. Every call clears the previous timer with clearTimeout() and sets a new one. The function runs only when no new call arrives for delay milliseconds. The func.apply(this, args) call passes on both this and all the arguments collected by ...args.

Throttling works differently. It's like the T-Rex feeding schedule: no matter how often it roars, it gets a portion at most once per set period.

// Throttling - limits the frequency of function execution
function throttle(func, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

The first call runs immediately and raises the inThrottle flag. Further calls within limit are simply dropped, not postponed. Once the limit has passed, the flag goes down and the next call gets through again. Watch out for a consequence of this version: the last call in a series may be lost, so after scrolling stops the function won't see the final position.

Now we connect debouncing to the dinosaur search field.

// Using debouncing for search
const searchInput = document.getElementById('dino-search');
const debouncedSearch = debounce((query) => {
  console.log(`Searching for dinosaurs: ${query}`);
  searchDinosaurs(query);
}, 300);

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

When you quickly type "Rex", the listener calls debouncedSearch() three times, but the search runs once, 300 milliseconds after the last character. The input event itself still fires on every character; we only change how often the expensive work runs. Test yourself: you type "T", after 200 milliseconds "R", and then stop typing. How many times does the search run? Once, for "TR", because the 200-millisecond pause was too short for the first timer to go off.

The same technique can be written without a helper function, with a single variable for the timer.

let timer;
searchInput.addEventListener('input', () => {
  clearTimeout(timer);
  timer = setTimeout(() => searchDinosaurs(searchInput.value), 300);
});

It's exactly the same mechanism: we clear the old timer and set a new one. The debounce() function simply wraps it in a reusable package that you can put around any function.

Throttling While Scrolling

For scrolling we want something else. The progress bar should update regularly during the movement, not only after it ends.

// Using throttling for scroll
const throttledScroll = throttle(() => {
  const scrollPosition = window.scrollY;
  updateNavigationHighlight(scrollPosition);
  updateProgressBar(scrollPosition);
}, 100);

window.addEventListener('scroll', throttledScroll);

Even if the browser reports scroll many times, the position from window.scrollY reaches the navigation at most once every 100 milliseconds, which means about ten times during one second of scrolling. The page still scrolls smoothly, because we only limit our function, not the scrolling itself.

Which Technique Should You Choose?

Choose debouncing when only the final value matters: search, validation after typing has finished, saving a draft, or recalculating the layout once the window has stopped resizing. Choose throttling when you need regular updates during a continuous action: scroll progress, tracking the cursor during mousemove, the position of a dragged element.

My advice: in production projects, reach for proven implementations, such as debounce and throttle from the lodash library, which also handle the trailing call. Still, write your own versions at least once, because only then do you truly understand what happens to the timer. In the sandbox below you'll see both techniques side by side and compare the number of calls. In the lesson on performance you'll meet more tools for taming frequent events.

Remember: debouncing is the raptor that waits for silence, and throttling is the feeding schedule that won't let anyone feed the T-Rex more often than needed.

Code for this lesson: index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Throttling and Debouncing</title>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
            margin: 0;
            padding: 20px;
            color: white;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
        }

        h1 {
            text-align: center;
            margin-bottom: 40px;
        }

        .demo-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
            gap: 30px;
        }

        .demo-box {
            background: rgba(0, 0, 0, 0.7);
            border-radius: 15px;
            padding: 25px;
            border: 2px solid #34495e;
        }

        h2 {
            color: #3498db;
            margin-bottom: 15px;
            font-size: 20px;
        }

        .description {
            color: #bdc3c7;
            font-size: 14px;
            margin-bottom: 20px;
            line-height: 1.5;
        }

        input[type="text"], textarea {
            width: 100%;
            padding: 12px;
            border: 2px solid #34495e;
            border-radius: 8px;
            background: rgba(255, 255, 255, 0.1);
            color: white;
            font-size: 16px;
            margin-bottom: 10px;
        }

        input:focus, textarea:focus {
            outline: none;
            border-color: #3498db;
        }

        .scroll-area {
            height: 200px;
            overflow-y: auto;
            background: rgba(255, 255, 255, 0.05);
            border-radius: 8px;
            padding: 15px;
            margin-bottom: 15px;
        }

        .scroll-content {
            height: 800px;
        }

        .counter-display {
            background: rgba(52, 152, 219, 0.2);
            border: 1px solid #3498db;
            border-radius: 8px;
            padding: 15px;
            text-align: center;
            margin-bottom: 15px;
        }

        .counter-number {
            font-size: 48px;
            font-weight: bold;
            color: #3498db;
        }

        .counter-label {
            font-size: 14px;
            color: #bdc3c7;
            margin-top: 5px;
        }

        .comparison-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 10px;
            margin-bottom: 15px;
        }

        .comparison-box {
            background: rgba(255, 255, 255, 0.05);
            border-radius: 8px;
            padding: 12px;
            text-align: center;
        }

        .comparison-number {
            font-size: 32px;
            font-weight: bold;
            color: #e74c3c;
        }

        .comparison-number.throttled {
            color: #27ae60;
        }

        .log {
            background: #000;
            border-radius: 8px;
            padding: 12px;
            max-height: 150px;
            overflow-y: auto;
            font-family: 'Courier New', monospace;
            font-size: 11px;
        }

        .log-entry {
            padding: 2px 0;
            color: #95a5a6;
        }

        .log-entry.throttled {
            color: #27ae60;
        }

        .log-entry.debounced {
            color: #3498db;
        }

        .info-tag {
            display: inline-block;
            padding: 4px 10px;
            border-radius: 12px;
            font-size: 12px;
            font-weight: bold;
            margin-bottom: 10px;
        }

        .tag-throttle {
            background: #27ae60;
            color: white;
        }

        .tag-debounce {
            background: #3498db;
            color: white;
        }

        .reset-btn {
            width: 100%;
            padding: 10px;
            background: #7f8c8d;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-weight: bold;
        }

        .reset-btn:hover {
            background: #95a5a6;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Throttling vs Debouncing in Jurassic Park</h1>

        <div class="demo-grid">
            <!-- Demo 1: Search with Debouncing -->
            <div class="demo-box">
                <div class="info-tag tag-debounce">DEBOUNCING</div>
                <h2>1. Dinosaur Search</h2>
                <p class="description">
                    Searching starts only after 500ms from the last keystroke. This saves API calls.
                </p>

                <input type="text" id="search-input" placeholder="Type a dinosaur name...">

                <div class="counter-display">
                    <div class="counter-number" id="search-count">0</div>
                    <div class="counter-label">Searches performed</div>
                </div>

                <div class="log" id="search-log"></div>
            </div>

            <!-- Demo 2: Scroll with Throttling -->
            <div class="demo-box">
                <div class="info-tag tag-throttle">THROTTLING</div>
                <h2>2. Position Monitoring</h2>
                <p class="description">
                    Position is checked at most every 200ms during scrolling. Improves performance.
                </p>

                <div class="scroll-area" id="scroll-area">
                    <div class="scroll-content">
                        <p>Scroll down to see throttling in action!</p>
                        <p style="margin-top: 100px;">Keep scrolling...</p>
                        <p style="margin-top: 100px;">Almost there...</p>
                        <p style="margin-top: 100px;">Further!</p>
                        <p style="margin-top: 100px;">The end!</p>
                    </div>
                </div>

                <div class="counter-display">
                    <div class="counter-number" id="scroll-count">0</div>
                    <div class="counter-label">Position checks (throttled)</div>
                </div>

                <div class="log" id="scroll-log"></div>
            </div>

            <!-- Demo 3: Comparison without/with optimization -->
            <div class="demo-box">
                <h2>3. Comparison: Input without/with Debouncing</h2>
                <p class="description">
                    Type something to see the difference between a normal handler and debounced.
                </p>

                <input type="text" id="comparison-input" placeholder="Test the difference...">

                <div class="comparison-grid">
                    <div class="comparison-box">
                        <div class="comparison-number" id="normal-count">0</div>
                        <div class="counter-label">Normal calls</div>
                    </div>
                    <div class="comparison-box">
                        <div class="comparison-number throttled" id="debounced-count">0</div>
                        <div class="counter-label">Debounced (300ms)</div>
                    </div>
                </div>

                <div class="log" id="comparison-log"></div>
            </div>

            <!-- Demo 4: Resize with Throttling -->
            <div class="demo-box">
                <div class="info-tag tag-throttle">THROTTLING</div>
                <h2>4. Textarea Resize Handler</h2>
                <p class="description">
                    On resize, the function is called at most every 250ms.
                </p>

                <textarea id="resize-textarea" rows="4" placeholder="Resize the browser window..."></textarea>

                <div class="comparison-grid">
                    <div class="comparison-box">
                        <div class="comparison-number" id="resize-normal">0</div>
                        <div class="counter-label">Normal resize</div>
                    </div>
                    <div class="comparison-box">
                        <div class="comparison-number throttled" id="resize-throttled">0</div>
                        <div class="counter-label">Throttled (250ms)</div>
                    </div>
                </div>

                <button class="reset-btn" id="reset-resize">Reset Counters</button>
            </div>
        </div>
    </div>

    <script>
        console.log('Event handler optimization system - START');

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

        // THROTTLE IMPLEMENTATION
        function throttle(func, limit) {
            let inThrottle;
            return function(...args) {
                if (!inThrottle) {
                    func.apply(this, args);
                    inThrottle = true;
                    setTimeout(() => inThrottle = false, limit);
                }
            };
        }

        // DEMO 1: Search with Debouncing
        const searchInput = document.getElementById('search-input');
        const searchCount = document.getElementById('search-count');
        const searchLog = document.getElementById('search-log');
        let searchCounter = 0;

        function logSearch(message, type = '') {
            const entry = document.createElement('div');
            entry.className = `log-entry ${type}`;
            entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
            searchLog.insertBefore(entry, searchLog.firstChild);
            if (searchLog.children.length > 10) {
                searchLog.removeChild(searchLog.lastChild);
            }
        }

        function performSearch(query) {
            searchCounter++;
            searchCount.textContent = searchCounter;
            logSearch(`Searching: "${query}"`, 'debounced');
            console.log(`API Call: Searching for dinosaur "${query}"`);
        }

        const debouncedSearch = debounce(performSearch, 500);

        searchInput.addEventListener('input', function(e) {
            const query = e.target.value;
            logSearch(`Typed: "${query}"`);
            debouncedSearch(query);
        });

        // DEMO 2: Scroll with Throttling
        const scrollArea = document.getElementById('scroll-area');
        const scrollCount = document.getElementById('scroll-count');
        const scrollLog = document.getElementById('scroll-log');
        let scrollCounter = 0;

        function logScroll(message, type = '') {
            const entry = document.createElement('div');
            entry.className = `log-entry ${type}`;
            entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
            scrollLog.insertBefore(entry, scrollLog.firstChild);
            if (scrollLog.children.length > 8) {
                scrollLog.removeChild(scrollLog.lastChild);
            }
        }

        function checkScrollPosition() {
            scrollCounter++;
            scrollCount.textContent = scrollCounter;
            const position = scrollArea.scrollTop;
            const percentage = Math.round((position / (scrollArea.scrollHeight - scrollArea.clientHeight)) * 100);
            logScroll(`Position: ${percentage}%`, 'throttled');
            console.log(`Scroll position: ${percentage}%`);
        }

        const throttledScroll = throttle(checkScrollPosition, 200);
        scrollArea.addEventListener('scroll', throttledScroll);

        // DEMO 3: Input Comparison
        const comparisonInput = document.getElementById('comparison-input');
        const normalCount = document.getElementById('normal-count');
        const debouncedCountDisplay = document.getElementById('debounced-count');
        const comparisonLog = document.getElementById('comparison-log');
        let normalCounter = 0;
        let debouncedCounter = 0;

        function logComparison(message, type = '') {
            const entry = document.createElement('div');
            entry.className = `log-entry ${type}`;
            entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
            comparisonLog.insertBefore(entry, comparisonLog.firstChild);
            if (comparisonLog.children.length > 8) {
                comparisonLog.removeChild(comparisonLog.lastChild);
            }
        }

        function normalHandler() {
            normalCounter++;
            normalCount.textContent = normalCounter;
            logComparison('Normal call');
        }

        function debouncedHandler() {
            debouncedCounter++;
            debouncedCountDisplay.textContent = debouncedCounter;
            logComparison('Debounced call', 'debounced');
        }

        const debouncedCompare = debounce(debouncedHandler, 300);

        comparisonInput.addEventListener('input', function() {
            normalHandler();
            debouncedCompare();
        });

        // DEMO 4: Resize with Throttling
        const resizeTextarea = document.getElementById('resize-textarea');
        const resizeNormal = document.getElementById('resize-normal');
        const resizeThrottled = document.getElementById('resize-throttled');
        const resetResizeBtn = document.getElementById('reset-resize');
        let resizeNormalCounter = 0;
        let resizeThrottledCounter = 0;

        function handleResize() {
            resizeNormalCounter++;
            resizeNormal.textContent = resizeNormalCounter;
            const width = window.innerWidth;
            const height = window.innerHeight;
            resizeTextarea.value = `Window size: ${width}x${height}px\nNormal calls: ${resizeNormalCounter}`;
        }

        function handleResizeThrottled() {
            resizeThrottledCounter++;
            resizeThrottled.textContent = resizeThrottledCounter;
            console.log(`Throttled resize: ${window.innerWidth}x${window.innerHeight}`);
        }

        const throttledResize = throttle(handleResizeThrottled, 250);

        window.addEventListener('resize', function() {
            handleResize();
            throttledResize();
        });

        resetResizeBtn.addEventListener('click', function() {
            resizeNormalCounter = 0;
            resizeThrottledCounter = 0;
            resizeNormal.textContent = '0';
            resizeThrottled.textContent = '0';
            resizeTextarea.value = '';
            console.log('Resize counters reset');
        });

        // Initialization
        logSearch('Search system ready', 'debounced');
        logScroll('Monitoring system ready', 'throttled');
        logComparison('Comparison system ready');

        console.log('All optimization systems active');
        console.log('Debounce: Delays execution until a pause');
        console.log('Throttle: Limits execution frequency');
    </script>
</body>
</html>

Docs entries used in this lesson

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the debounce technique do when applied to the input event?

  2. 2. Which event is best limited with throttle so the function runs e.g. every 100 ms?

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the debounce function call (one line, left to right):

Useful articles