Debounce and Throttle Event Handlers in JavaScript for WordPress Admin Panels

Debouncing and throttling are two distinct rate-limiting strategies for event handlers that fire at high frequency: debounce delays execution until the event stream has paused for a specified duration, while throttle guarantees execution at most once per interval regardless of how many events fire. The canonical use case for debounce in WordPress admin panels is a live search or slug-generation input: firing an AJAX request on every input event would send hundreds of requests per second during fast typing — a 300 ms debounce collapses the event burst into a single request that fires 300 ms after the user stops typing. Throttle is the correct choice for events that must receive updates at a consistent rate: a scroll listener that updates a sticky-panel position should fire at 60 fps (every 16 ms) rather than hundreds of times per second, and a window resize listener that redraws a chart should fire at most once per 200 ms. Both patterns are trivially implementable in vanilla JavaScript without Lodash — debounce uses clearTimeout + setTimeout, and throttle uses a timestamp comparison or requestAnimationFrame for scroll events. The leading option in debounce fires the first event immediately (useful for button clicks that should feel instant) and then suppresses subsequent calls until the quiet period ends; the trailing option (the default) fires after the quiet period. Throttle with requestAnimationFrame is preferred over setTimeout(fn, 16) for visual updates because it synchronizes with the browser’s repaint cycle, preventing layout thrashing and dropped frames. AJAX search in the WordPress post list screen typically uses a 400 ms debounced input handler that calls wp.ajax.send() or fetch() to admin-ajax.php — the debounce also provides a natural cancellation point to abort in-flight requests using AbortController before sending a new one. The character counter post uses the input event at full frequency because counter updates are synchronous DOM writes — debounce would introduce noticeable lag; use it only for expensive async operations.

Problem: Event handlers attached to input, scroll, and resize events in WordPress admin panels fire hundreds of times per second, triggering redundant AJAX requests, expensive DOM measurements, and chart redraws that cause jank and exhaust server resources.

Solution: Wrap AJAX-triggering input handlers in a debounce that delays execution until typing pauses, wrap scroll and resize handlers in a requestAnimationFrame throttle, and cancel in-flight requests with AbortController before each new debounced call.

/**
 * Debounce: delays fn until after `wait` ms of inactivity.
 * @param {Function} fn
 * @param {number}   wait      ms to wait after the last call
 * @param {boolean}  leading   if true, also fire on the leading edge
 */
function debounce(fn, wait = 300, leading = false) {
    let timer;
    return function (...args) {
        const callNow = leading && !timer;
        clearTimeout(timer);
        timer = setTimeout(() => {
            timer = undefined;
            if (!leading) fn.apply(this, args);
        }, wait);
        if (callNow) fn.apply(this, args);
    };
}

/**
 * Throttle via requestAnimationFrame: fires fn at most once per animation frame.
 * Best for scroll/resize handlers that update the DOM.
 * @param {Function} fn
 */
function rafThrottle(fn) {
    let rafId = null;
    return function (...args) {
        if (rafId !== null) return;
        rafId = requestAnimationFrame(() => {
            fn.apply(this, args);
            rafId = null;
        });
    };
}

// ── Usage 1: debounced AJAX search with AbortController ──────────────────
let currentController = null;

const searchInput = document.getElementById('post-search-input');

const handleSearch = debounce(async function (e) {
    const q = e.target.value.trim();
    if (q.length < 2) return;

    // Cancel any in-flight request before sending a new one
    if (currentController) currentController.abort();
    currentController = new AbortController();

    try {
        const res = await fetch(
            ajaxurl + '?action=my_search&q=' + encodeURIComponent(q),
            { signal: currentController.signal }
        );
        const data = await res.json();
        renderResults(data);
    } catch (err) {
        if (err.name !== 'AbortError') console.error('Search failed:', err);
    }
}, 400);

searchInput?.addEventListener('input', handleSearch);

// ── Usage 2: RAF-throttled scroll handler ────────────────────────────────
const stickyPanel = document.getElementById('sticky-panel');

window.addEventListener('scroll', rafThrottle(function () {
    const scrolled = window.scrollY > 120;
    stickyPanel?.classList.toggle('is-sticky', scrolled);
}), { passive: true });

NOTE: Add { passive: true } to scroll and touchstart event listeners — it signals to the browser that the handler will never call preventDefault(), allowing the browser to start scrolling without waiting for the JavaScript to finish, which eliminates scroll jank on mobile devices. Passive listeners cannot call preventDefault(); if you need to block scrolling in some condition, omit the passive flag for that specific listener.