Throttle and debounce scroll and resize events in JavaScript

Throttling and debouncing are two techniques for controlling how often a function fires in response to high-frequency events like scroll, resize, mousemove, and input. Without them, a scroll listener on a page with a parallax effect or a sticky header can fire 60–100 times per second, blocking the main thread and causing janky animations. Debounce delays execution until the event has stopped firing for a specified wait period — ideal for search-as-you-type (fire the API call only after the user stops typing for 300ms). Throttle limits execution to at most once per specified interval regardless of how many events fire — ideal for scroll and resize handlers where you want regular updates but not every single pixel of movement. A debounced resize handler will only fire once the user has finished resizing; a throttled one fires every 200ms while they are resizing. Both are implemented with setTimeout/clearTimeout wrappers and can be written in about 10 lines each — no library required. For WordPress projects, this knowledge pairs directly with the debounce guide (which covers input specifically), the screen size detection guide, and the Intersection Observer guide — which replaces scroll listeners for most viewport-detection use cases entirely.

Problem: Scroll and resize event listeners in your WordPress theme fire hundreds of times per second, causing performance issues and janky animations.

Solution: Add the following utility functions to your theme’s JavaScript file:

// Debounce: delay until events stop
function debounce(fn, wait = 300) {
    let timer;
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), wait);
    };
}

// Throttle: at most once per interval
function throttle(fn, limit = 200) {
    let lastRun = 0;
    return function(...args) {
        const now = Date.now();
        if (now - lastRun >= limit) {
            lastRun = now;
            fn.apply(this, args);
        }
    };
}

// Usage examples

// Throttled scroll: update sticky header class at most every 100ms
const handleScroll = throttle(() => {
    const header = document.querySelector('.site-header');
    if (!header) return;
    header.classList.toggle('scrolled', window.scrollY > 80);
}, 100);

window.addEventListener('scroll', handleScroll, { passive: true });

// Debounced resize: recalculate layout only after resize finishes
const handleResize = debounce(() => {
    console.log('Resize complete:', window.innerWidth, window.innerHeight);
    // recalculate grid column widths, re-init sliders, etc.
}, 250);

window.addEventListener('resize', handleResize);

// Debounced search: fire AJAX only after user stops typing
const searchInput = document.querySelector('#live-search');
if (searchInput) {
    searchInput.addEventListener('input', debounce(async (e) => {
        const query = e.target.value.trim();
        if (query.length < 3) return;
        const res   = await fetch(`/wp-json/wp/v2/posts?search=${encodeURIComponent(query)}&per_page=5`);
        const posts = await res.json();
        renderSearchResults(posts);
    }, 350));
}

// requestAnimationFrame throttle (best for visual updates)
function rafThrottle(fn) {
    let rafId = null;
    return function(...args) {
        if (rafId) return;
        rafId = requestAnimationFrame(() => {
            fn.apply(this, args);
            rafId = null;
        });
    };
}

// Scroll handler synced to display refresh rate (60fps max)
window.addEventListener('scroll', rafThrottle(() => {
    // Parallax, progress bar, or other visual-sync work here
}), { passive: true });

NOTE: Always add { passive: true } to scroll event listeners — this tells the browser the listener will never call preventDefault(), allowing it to start scrolling immediately on the GPU compositor thread without waiting for your JavaScript to run. Without passive: true, Chrome and Firefox show a console warning and scroll performance degrades. For animations tied to scroll position, requestAnimationFrame throttling (the rafThrottle pattern above) is better than a fixed millisecond interval because it syncs exactly to the display refresh rate — no wasted frames, no missed updates.