Performance and Scheduling Patterns

Performance is a budget: one main thread, 60 frames a second. This lesson covers the patterns that keep interactive JavaScript inside that budget.

The frame budget

16 ms — where the number comes from

A 60 fps display shows a new frame every 1000/60 ≈ 16.7 ms. That is the total allowance for everything in lesson 16's pipeline: your event handlers, style, layout, paint. Miss it once and one frame drops — usually invisible. Miss it every frame and the page feels broken. This is why lesson 19's "don't block the thread" is a budget, not a slogan: there is a number attached.

// Measure your own frame budget compliance (browser):
function frame(now) {
  const delta = now - last; // time since the previous frame
  if (delta > 20) console.warn(`dropped frames: ${delta.toFixed(1)}ms`); // missed budget
  last = now;
  requestAnimationFrame(frame);
}
let last = performance.now();
requestAnimationFrame(frame);

Debounce versus throttle

Two different regulators — pick by intent

// THROTTLE: at most once per interval DURING activity.
// Use when you want continuous updates: scroll position, mousemove tracking.
function throttle(fn, ms) {
  let ready = true;
  return (...args) => {
    if (!ready) return;
    ready = false;
    fn(...args);
    setTimeout(() => (ready = true), ms); // reopen the gate after ms
  };
}

// DEBOUNCE: only after QUIET. Use when you want a final answer: search-as-you-type,
// resize-end. Every new event cancels the pending call.
function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);          // cancel the last scheduled call…
    timer = setTimeout(() => fn(...args), ms); // …and reschedule
  };
}

// Demo 31 (demo/async/31_scheduling_lab.html) visualizes both side by side.

The one-line version: throttle = at most once per X ms; debounce = once, X ms after you stop. Confusing them ships search boxes that never search while typing (debounced button) or scroll logs 200 times a second (debounced where throttled was meant).

Scheduling: rAF and yielding

Break up long tasks

Any task over ~50 ms is a "long task": it delays every queued event and at least one frame. The fix is not a faster loop — it is yielding: split the work so the thread can serve input and rendering between chunks:

// Yield to the main thread between chunks (browser):
async function processInChunks(items, fn) {
  for (let i = 0; i < items.length; i++) {
    fn(items[i]);
    if (i % 100 === 0) {
      await new Promise((r) => setTimeout(r, 0)); // hand the thread back:
      // queued clicks and render steps get their turn here
    }
  }
}

The cost: total wall time grows slightly. The win: the page stays responsive throughout. For work that does not need to finish this frame, that trade is almost always right. CPU-bound work that cannot be chunked belongs in a worker (lesson 22).

Visual work belongs in requestAnimationFrame

// setTimeout fires whenever the queue allows — mid-layout, too early, or too late.
// rAF fires exactly at the next frame, BEFORE paint — one layout per frame:
function animate(now) {
  box.style.transform = `translateX(${(now - start) / 10}px)`; // transform: no reflow
  if (running) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Animating transform and opacity (compositor-friendly, lesson 16) inside rAF keeps the frame budget intact; animating layout properties inside setTimeout breaks both rules at once.

Measuring: marks and measures

Instrument the code, not the wall clock

// performance.mark/measure: named timestamps the Performance panel displays
// alongside its own recording — your phases aligned with the browser's.
performance.mark("search:start");
const results = await search(query);
performance.mark("search:end");
performance.measure("search", "search:start", "search:end");

console.log(performance.getEntriesByName("search")[0].duration); // e.g. 41.237

Named measures survive refactors and show up as user-owned tracks in the panel — the difference between "the page feels slow" and "search takes 41 ms, budget was 16".

Practice: inside the budget

The task

Open demo/async/31_scheduling_lab.html (Preview link on the Lab Examples page). Move the pointer and compare raw, throttled, and debounced counts. Then implement the chunked processor from this lesson over a 10,000-item array and watch the frame meter: before chunking, frames drop; after, they hold. Finish by adding a performance.measure around the whole batch and reading the number.

The checklist

  • You can say where 16 ms comes from and what it must cover.
  • You pick throttle for continuous updates, debounce for quiet-then-final.
  • You yield to the main thread in chunks instead of blocking once.
  • You measure with marks and measures, not with guesses.