[JavaScript Series 10] Improving Performance with the Event Loop, Debounce, and Throttle

한국어 버전

User actions like scrolling, resizing, or typing can trigger a burst of events and slow a page down. If you understand how the browser schedules work and which control knobs you have, you can decide exactly where to slow things down. The focus of this lesson is "event loop order + debounce/throttle." Extras like scheduleRender microtasks are marked as optional depth.

Key terms

  1. Microtask: A fast queue (Promises, queueMicrotask) that gets to run within the same frame before other callbacks.
  2. Debounce: A pattern that collapses repeated calls into the final call after a quiet period.
  3. Throttle: A limiter that allows only one call per fixed interval so bursts cannot overwhelm the UI thread.
  4. Passive listener: An addEventListener option { passive: true } that lets the browser optimize scroll-like events because it knows you won't call preventDefault().

Core ideas

  • Event loop: JavaScript shuffles between "work now" and "work later" queues. Promise callbacks go to the microtask queue (more urgent) while setTimeout callbacks go to the macrotask queue (less urgent).
  • Debounce: Collect rapid-fire events and run the handler once after the noise stops.
  • Throttle: Guarantee a maximum frequency by enforcing a minimum gap between handler executions.
  • Microtask rendering: Reserving a render inside Promise.resolve().then() keeps duplicate renders within a single frame from stacking up.
  • Performance logging: Use DevTools Performance and call-count logs to prove your optimization works.

Visualizing the event loop flow

EventLoopJSStackMicrotaskQueueMacrotaskQueueRendererSynchronous codePromise/queueMicrotasksetTimeout, I/OBrowser render Sync code registers PromisesRun microtasksRegister timersTimer callbacksRender if frame is freeNext tick

This diagram visualizes the sequence: synchronous work → queued microtasks → more relaxed macrotasks → rendering. In practice, Promise.then fires right away while setTimeout waits for the next round.

Code examples

Start with a tiny log that makes the ordering obvious. The main exercise is to read sequence and call counts.

Baseline order demo
A: Button click
D: Synchronous code
C: Promise.then
B: setTimeout

Scroll handler call count comparison
- Before throttle: 24 calls
- After throttle: 5 calls

These numbers show how Promise beats setTimeout to the punch, and how throttling slashes handler calls.

console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");
// Order: A, D, C, B

Because microtasks run before macrotasks, scheduling UI cleanup inside Promise.then keeps the interface snappy. "A, D" are synchronous, "C" is a microtask, and "B" is a macrotask.

function debounce(fn, delay = 200) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = window.setTimeout(() => {
      fn.apply(null, args);
    }, delay);
  };
}

const handleSearch = debounce((keyword) => {
  fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
    .then((res) => res.json())
    .then(renderResults);
}, 400);

document.querySelector(".search-input")?.addEventListener("input", (event) => {
  const target = event.target;
  if (target instanceof HTMLInputElement) handleSearch(target.value);
});

Debounce triggers the request only after typing stops for a bit, which saves bandwidth and server work. Think of it as "call only during breaks."

function throttle(fn, interval = 200) {
  let lastTime = 0;
  let timer;

  return (...args) => {
    const now = Date.now();
    const remaining = interval - (now - lastTime);

    if (remaining <= 0) {
      lastTime = now;
      fn.apply(null, args);
      return;
    }

    clearTimeout(timer);
    timer = window.setTimeout(() => {
      lastTime = Date.now();
      fn.apply(null, args);
    }, remaining);
  };
}

const reportScroll = throttle(() => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  document
    .querySelector(".progress-bar")
    ?.setAttribute("style", `transform: scaleX(${progress})`);
}, 100);

window.addEventListener("scroll", reportScroll, { passive: true });

Throttle guarantees a steady interval so even hundreds of scroll events per second do not cause layout thrashing. You stay responsive while limiting work to "only a few times per second."

let pendingRender = false;

function scheduleRender() {
  if (pendingRender) return;
  pendingRender = true;
  Promise.resolve().then(() => {
    render();
    pendingRender = false;
  });
}

function updateState(partial) {
  Object.assign(state, partial);
  scheduleRender();
}

Microtask scheduling prevents duplicate renders so frames stay available even during scroll. Treat this block as optional depth once the core debounce/throttle patterns feel natural.

Why it matters

  • Debouncing search inputs or autosave forms can dramatically cut request counts.
  • Throttling scroll and resize handlers keeps animations smooth on mobile hardware.
  • Understanding the event loop lets you pinpoint where performance bottlenecks appear.

Practice

  • Core: Rebuild the provided debounce/throttle helpers and wire them to a search input plus a scroll progress bar.
  • Stretch: Try a requestAnimationFrame-based throttle or a microtask render scheduler to avoid duplicate renders.
  • Debugging: Comment out clearTimeout to see duplicate calls flood the console, then compare before/after counts.
  • Done when: DevTools Performance or console metrics prove the call volume actually dropped.

Wrap-up

Knowing the event loop and these control levers prevents UI stalls when user input spikes. Next up, we will pair these responsiveness tricks with component structure to form reusable patterns.

💬 댓글

이 글에 대한 의견을 남겨주세요