Performance and Scheduling Patterns
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.