jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Debounce, Throttle & rAF — rate-limiting high-frequency browser events

Debounce vs throttle vs rAF — from-scratch implementations, leading/trailing/maxWait, passive listeners, scrollend, and React pitfalls.

Các DOM event tần suất cao là một trong những bẫy performance phổ biến nhất trong frontend. input, scroll, resize, mousemove, pointermove, input, scroll, resize, mousemove, pointermove, và wheel có thể fire hàng chục đến hàng trăm lần mỗi giây. Nếu mỗi lần fire đều kích hoạt API call, layout read, hoặc React re-render, main thread sẽ nghẽn.

Bài này cover ba chiến lược gom event — debounce, throttle, và rAF batching — kèm implementation đúng production, khi nào chọn cái nào, và các bug vẫn cắn senior engineer.

Mở demo đầy đủ:


Mục lục

  1. The problem — event storms
  2. Debounce — wait for silence
  3. Debounce from scratch — this, args, cancel, flush, maxWait
  4. Throttle — cap the rate
  5. Throttle from scratch — timestamp vs timer
  6. requestAnimationFrame — coalesce visual updates
  7. When NOT to debounce/throttle — native alternatives
  8. Passive listeners & scroll performance
  9. Common bugs in production
  10. Comparison table & decision guide
  11. Checklist

1. Vấn đề — bão event

Xét một ô search với input → fetch gợi ý. Người gõ nhanh tạo ~8–12 input event/giây; paste có thể fire một event mỗi ký tự trong một burst. Không gom event, bạn gửi 12 HTTP request cho query 12 ký tự — 11 cái lãng phí.

Scroll còn tệ hơn. Một cú flick trên trackpad có thể emit 100+ scroll event trong dưới 1 giây. Gắn handler đọc getBoundingClientRect() hoặc update React state mỗi event là công thức gây jank.

User action          Events/sec (typical)    Naive handler cost
─────────────────────────────────────────────────────────────────
input (fast typing)  8–15                    N API calls
scroll (trackpad)    60–120                  N layout reads + paints
resize (drag)        30–60                   N reflows + chart redraws
mousemove (drag)     60–120                  N DOM updates
pointermove (touch)  30–90                   N hit-testing loops

Fix không phải “bỏ listener” — mà là gom nhiều event thành ít lần chạy handler hơn nhưng vẫn giữ đúng hành vi user thấy.

Quy tắc ngón cái: Nếu handler làm I/O, đo layout, hoặc trigger re-render, gần như chắc chắn cần gom event.


2. Debounce — đợi im lặng

Debounce trì hoãn execution cho đến khi event ngừng đến trong khoảng wait cấu hình. Mỗi event mới reset timer.

Events:  ──x──x──x──x──x──────────────x──x────────
                              ↑ quiet window
Debounced handler fires:      ●                 ●

Dùng debounce khi:

  • Search-as-you-type (đợi user dừng gõ)
  • Auto-save draft trên input
  • resize → tính lại layout sau khi kéo xong
  • Window resize → rebuild chart sau khi user ngừng kéo

Không debounce khi cần phản hồi định kỳ trong hành động liên tục — vd indicator vị trí scrollbar hoặc drag preview. Đó là việc của throttle hoặc rAF.

Leading vs trailing

ModeFiresTypical use
Trailing (default)After silenceSearch, auto-save
LeadingOn first event, then suppressSubmit button double-click guard
BothFirst + last in a burstRare; prefer explicit UX

Hầu hết UI library mặc định trailing vì nó capture state cuối cùng.


3. Debounce từ đầu — this, args, cancel, flush, maxWait

Debounce production phải giữ this context và arguments, và expose cancel() / flush() cho cleanup và trigger thủ công.

function debounce(fn, wait, options = {}) {
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;
  let lastCallTime = null;
  let result;

  const { leading = false, trailing = true, maxWait } = options;

  function invokeFunc(time) {
    const args = lastArgs;
    const thisArg = lastThis;
    lastArgs = lastThis = null;
    lastCallTime = time;
    result = fn.apply(thisArg, args);
    return result;
  }

  function startTimer(pendingFunc, waitMs) {
    timerId = setTimeout(pendingFunc, waitMs);
  }

  function cancel() {
    if (timerId !== null) clearTimeout(timerId);
    timerId = lastArgs = lastThis = lastCallTime = null;
  }

  function flush() {
    if (timerId === null) return result;
    clearTimeout(timerId);
    return invokeFunc(Date.now());
  }

  function debounced(...args) {
    const time = Date.now();
    const isInvoking = timerId === null;

    lastArgs = args;
    lastThis = this;

    if (isInvoking) {
      lastCallTime = time;
    }

    const shouldCallLeading = leading && isInvoking;
    const timeSinceLastCall = time - (lastCallTime ?? 0);
    const shouldMaxWait =
      maxWait != null &&
      timeSinceLastCall >= maxWait &&
      timerId !== null;

    if (shouldCallLeading) {
      invokeFunc(time);
    }

    if (timerId !== null) clearTimeout(timerId);

    if (shouldMaxWait) {
      invokeFunc(time);
    } else if (trailing) {
      startTimer(() => {
        const trailingEdge = Date.now();
        const shouldInvoke =
          timerId !== null &&
          trailing &&
          lastArgs != null;
        timerId = null;
        if (shouldInvoke) invokeFunc(trailingEdge);
      }, wait);
    } else {
      startTimer(() => { timerId = null; }, wait);
    }

    return result;
  }

  debounced.cancel = cancel;
  debounced.flush = flush;
  return debounced;
}

maxWait — lối thoát

Debounce trailing thuần có thể đợi mãi nếu event không bao giờ dừng — vd mousemove khi drag hoặc websocket push liên tục. maxWait đảm bảo ít nhất một lần gọi mỗi N ms bất kể input liên tục.

// Auto-save at most every 2s, but also after 300ms of silence
const save = debounce(persistDraft, 300, { maxWait: 2000 });

Pattern cleanup

const onResize = debounce(relayout, 150);

window.addEventListener('resize', onResize);

// On teardown (SPA route change, component unmount):
onResize.cancel();
window.removeEventListener('resize', onResize);

Cạm bẫy: flush() trước khi unmount nếu phải persist giá trị pending cuối — vd auto-save debounced trên form user navigate away giữa chừng gõ.


4. Throttle — giới hạn tần suất

Throttle đảm bảo handler chạy tối đa một lần mỗi interval, bất kể bao nhiêu event đến.

Events:  ──x─x─x─x─x─x─x─x─x─x─x─x─x─x─x─x─x─x─x─x──
Throttled (100ms): ●───────●───────●───────●───────●

Dùng throttle khi:

  • Theo dõi vị trí scroll (sticky header, progress bar)
  • mousemove → update vị trí drag ghost
  • Spam nút / rate-limit hành động user
  • Beacon analytics “user scrolled” trong lúc scroll active

5. Throttle từ đầu — timestamp vs timer

Có hai implementation kinh điển. Phiên bản timestamp đơn giản hơn và phù hợp leading-edge throttle.

Timestamp throttle (leading)

function throttleLeading(fn, wait) {
  let lastCall = 0;

  return function throttled(...args) {
    const now = Date.now();
    if (now - lastCall >= wait) {
      lastCall = now;
      return fn.apply(this, args);
    }
  };
}

Hybrid throttle (leading + trailing tùy chọn)

Khớp hành vi kiểu lodash và là thứ hầu hết team mong đợi.

function throttle(fn, wait, options = {}) {
  let lastCall = 0;
  let timerId = null;
  let lastArgs = null;
  let lastThis = null;

  const { leading = true, trailing = false } = options;

  function invoke() {
    lastCall = Date.now();
    timerId = null;
    fn.apply(lastThis, lastArgs);
    lastArgs = lastThis = null;
  }

  function throttled(...args) {
    const now = Date.now();
    const remaining = wait - (now - lastCall);

    lastArgs = args;
    lastThis = this;

    if (remaining <= 0 || remaining > wait) {
      if (timerId !== null) {
        clearTimeout(timerId);
        timerId = null;
      }
      if (leading) {
        invoke();
      } else {
        lastCall = now;
      }
    } else if (trailing && timerId === null) {
      timerId = setTimeout(invoke, remaining);
    }
  }

  throttled.cancel = () => {
    if (timerId !== null) clearTimeout(timerId);
    timerId = lastArgs = lastThis = null;
    lastCall = 0;
  };

  return throttled;
}

Timestamp vs chỉ timer

ApproachProsCons
TimestampNo drift; predictable rateTrailing edge needs extra timer
Timer-only (setInterval)Simple mental modelDrifts under load; hard to cancel
HybridLeading + trailing optionsSlightly more code

Ưu tiên timestamp hybrid cho scroll/mousemove. Throttle chỉ timer với setInterval gần như luôn sai trong code event-driven.


6. requestAnimationFrame — gom update visual

requestAnimationFrame (rAF) lên lịch callback trước frame paint tiếp theo, tối đa ~60 lần/giây (hoặc tần refresh màn hình). Đúng công cụ khi handler chỉ mutate visual — vị trí DOM, vẽ canvas, CSS transform.

function rafThrottle(fn) {
  let scheduled = false;
  let lastArgs = null;
  let lastThis = null;

  return function coalesced(...args) {
    lastArgs = args;
    lastThis = this;
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      fn.apply(lastThis, lastArgs);
    });
  };
}

rAF vs throttle cho visual

rAFThrottle (16ms)
Syncs with paintYesNo — may run between frames
Fires when tab hiddenNo (paused)Yes — wastes work
Best forDOM/canvas visual updatesNon-visual side effects during scroll

Ví dụ — parallax khi scroll:

let ticking = false;

function onScroll() {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const y = window.scrollY;
    hero.style.transform = `translateY(${y * 0.4}px)`;
    ticking = false;
  });
}

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

Đây là pattern “rAF gate” — cờ boolean + một slot rAF. Về chức năng giống hệt rafThrottle ở trên.

Đừng rAF-debounce API call. rAF gom công việc paint; network I/O thuộc debounce/throttle hoặc pattern abort-controller.


7. Khi KHÔNG debounce/throttle — alternative native

Đôi khi browser đã giải quyết vấn đề. Đừng chồng debounce lên không có lý do.

ResizeObserver thay window.resize

window.resize chỉ fire khi viewport đổi. Thay đổi kích thước element (sidebar collapse, flex reflow) cần ResizeObserver — engine đã batch và async sẵn. Xem Observer APIs deep dive cho phần đầy đủ.

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    chart.resize(entry.contentRect.width, entry.contentRect.height);
  }
});
ro.observe(containerEl);

IntersectionObserver thay scroll + getBoundingClientRect

Cho lazy-load, sentinel infinite-scroll, và sticky analytics, IntersectionObserver thay hẳn scroll listener. Chi tiết trong Infinite scroll & virtual scroll.

Event scrollend

Chrome 114+ và Firefox 109+ fire scrollend khi scroll hoàn tất (kể cả momentum). Dùng cho side effect “user scroll xong” thay vì debounce scroll.

scrollContainer.addEventListener('scrollend', () => {
  persistScrollPosition();
  fireAnalyticsBeacon();
});

Fallback browser cũ: debounce scroll với wait ~150ms.


8. Passive listener & scroll performance

Scroll jank thường do scroll listener chặn. Browser giả định listener touchstart / touchmove / wheel có thể gọi preventDefault(), nên đợi JS trước khi scroll.

Đánh dấu listener { passive: true } khi không bao giờ prevent default:

window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('wheel', onWheel, { passive: true });
element.addEventListener('touchmove', onTouchMove, { passive: true });

Không thể { passive: true } rồi gọi preventDefault() — browser bỏ qua và log violation. Overlay khóa scroll custom cần { passive: false } tường minh và dùng tiết kiệm.

Passive + rAF/throttle là combo chuẩn cho scroll handler mượt.


9. Bug phổ biến trong production

Tạo lại debounced function mỗi render

// ❌ React — new function every render, timer never settles
function SearchBox() {
  const [q, setQ] = useState('');
  const search = debounce((term) => fetchResults(term), 300);
  return <input onChange={(e) => { setQ(e.target.value); search(e.target.value); }} />;
}
// ✅ Stable reference via useMemo or useRef
function SearchBox() {
  const search = useMemo(
    () => debounce((term) => fetchResults(term), 300),
    []
  );
  useEffect(() => () => search.cancel(), [search]);
  // ...
}

useRef + lazy init cũng hợp lệ khi cần closure mới nhất mà không tạo lại debounced fn.

Quên cleanup khi unmount

Timer leak và fire trên component đã unmount → “Can’t perform a React state update on an unmounted component”.

useEffect(() => {
  const onScroll = throttle(updateProgress, 100);
  window.addEventListener('scroll', onScroll, { passive: true });
  return () => {
    onScroll.cancel();
    window.removeEventListener('scroll', onScroll);
  };
}, []);

Debounce scroll khi cần phản hồi live

Scroll debounced cảm giác trễ cho sticky header và progress bar đọc bài. Dùng throttle hoặc rAF cho UI live; giữ debounce/scrollend cho side effect sau scroll.

Stale closure trong API call throttle

Nếu fn throttle đóng gói props/state từ render N, có thể gửi filter lỗi thời. Truyền giá trị mới qua arguments (implementation trên luôn forward ...args) hoặc đọc từ ref.

Layout thrashing trong handler đã gom

Gom giảm số lần gọi nhưng không sửa pattern read-write-read-write DOM trong handler. Batch read, rồi write.

const onScroll = rafThrottle(() => {
  // ❌ interleaved reads and writes
  el.style.width = el.offsetWidth + 10 + 'px';

  // ✅ read phase, then write phase
  const w = el.offsetWidth;
  el.style.width = w + 10 + 'px';
});

10. Bảng so sánh & hướng chọn

StrategyFires whenCall count (100 events / 1s)Best for
None (raw)Every event~100Debugging only
Debounce 200msAfter 200ms silence1–5Search, auto-save, post-resize layout
Debounce + maxWaitSilence OR max intervalBoundedLive streams + must persist periodically
Throttle 100msEvery 100ms during stream~10Scroll tracking, drag move, analytics
rAF gateOnce per frame (~16ms)~60 maxVisual DOM/canvas updates
scrollendScroll completes1 per gesturePersist position, post-scroll analytics
ResizeObserverElement size changesBatched by engineComponent resize, charts
IntersectionObserverVisibility crosses thresholdBatched by engineLazy load, infinite scroll sentinel
                    Need live feedback during action?

                    ┌─────────┴─────────┐
                   YES                  NO
                    │                    │
            Visual only?          Debounce (trailing)
                    │             or scrollend / maxWait
          ┌────────┴────────┐
         YES                NO
          │                  │
       rAF gate          Throttle

11. Checklist

Trước khi ship handler tần suất cao:

  • Xác định nguồn event và tần suất fire điển hình
  • Chọn debounce vs throttle vs rAF vs native observer — không “luôn debounce”
  • Handler giữ this và args mới nhất
  • cancel() / flush() khi unmount; listener đã remove
  • Listener scroll/touch/wheel đánh dấu { passive: true } trừ khi prevent default
  • Không tạo lại fn debounced/throttled mỗi React render
  • Update visual dùng rAF; I/O dùng debounce/throttle
  • Cân nhắc scrollend, ResizeObserver, IntersectionObserver trước khi tự viết

Debounce, throttle, và rAF không phải utility thay thế nhau — chúng encode contract khác nhau về khi nào công việc chạy so với input user. Chọn contract khớp UX, implement một lần với reference ổn định và cleanup đúng, và bão event không còn là vấn đề.