Debounce & throttle
Learn debounce, throttle, leading and trailing calls, maxWait, requestAnimationFrame throttling, and cancellation for noisy JavaScript events.
- 01Limit events that fire oftenPick debounce, throttle, or requestAnimationFrame throttle for input, scroll, resize, mouse, and pointer events.
- 02Build full wrappersKeep
thisand the arguments, run leading and trailing calls, addmaxWait, and offer.cancel()and.flush(). - 03Avoid bugs in real appsMake each wrapper once, clean up when a component is removed, stop old requests, and test timelines with a fake clock.
Limit noisy event work
Some browser events fire very often. An input event fires on every key press. scroll, resize, mousemove, and pointermove can fire many times per second. Often the user only sees one real change at the end.
Some work is slow or costly. Examples are sending a request to a server, measuring the page, a big calculation, or drawing part of the page again. If that work runs on every event, the browser gets busy and the page feels slow.
A wrapper is a new function that you call instead of the real one. The wrapper decides when the real function runs. This lesson builds three wrappers:
- Debounce waits until the calls stop for
waitmilliseconds. Then it runs the real function once, with the arguments from the last call. - Throttle runs the real function at most once per time period, such as once every 100 milliseconds, while the calls keep coming.
- requestAnimationFrame throttle (rAF throttle for short) runs it at most once each time the browser draws the page. Each drawing is called a frame.
The table compares these three wrappers, plus a fourth choice: using CSS instead of JavaScript. Each row says how often the work runs and where it fits.
| Tool | How often the work runs | Good for |
|---|---|---|
| Debounce | Once, after the calls stop for wait ms. It uses the arguments from the last call. | A search box, autosave, work after resizing ends, checking a field after typing. |
| Throttle | At most once per interval, at the start or the end of the interval. | Tracking the scroll position, drag handlers, a button that acts once per second, analytics. |
| rAF throttle | At most once per frame, with the newest arguments. | A preview that follows the pointer, moving things as the page scrolls, drawing on a canvas. |
| CSS instead | No JavaScript runs. The browser does the work itself. | Scroll-driven animations, position: sticky, content-visibility, CSS transitions. |
The timers lesson showed how to cancel a timer. The classic utilities lesson built tiny versions of debounce and throttle. This lesson builds full versions, the kind a real app uses. They add:
- leading and trailing calls: running at the start or at the end of a wait,
maxWait: the longest the function may wait,- keeping
thisand the arguments, cancel()andflush(), to throw away or finish waiting work,- tests with a fake clock, so every run gives the same result.
Why noisy events need a limit
LIVE COUNTSYou can still listen to every event. Noticing an event is cheap. The costly work inside the handler is what needs a limit: network requests, changes to the page, and updates to your app's data. That work only needs to run as often as the user can notice.
const debounced = debounce(updateSearch, 400);const throttled = throttle(updateScroll, 300);const visual = rafThrottle(updatePointerPreview); function onNoisyEvent(value) { rawCount += 1; debounced(value); // waits for quiet throttled(value); // at most once per interval visual(value); // once per animation frame}- Raw
- 0
- Debounced
- 0
- Throttled
- 0
- rAF
- 0
- Events will appear here.
Type in the box or move the pointer inside the pad. Each event goes to all three wrappers.
Type quickly in the box, then stop. The Raw count goes up on every key press. The Debounced count waits until you stop. Now move the pointer inside the pad. The rAF count goes up at most once per frame, and it always uses the newest pointer position. The main thread lesson explains why each change to the page should be small and planned like this.
Debounce waits for quiet
STEP THROUGHUse debounce when only the final value matters, not the values in between. For example:
- a search box that searches for the finished word, not for every letter,
- autosave after the user stops typing,
- checking a form field after the user pauses,
- work that should run once the user stops resizing the window.
Picture elevator doors that open again each time someone steps in. The elevator only leaves when nobody has come in for a few seconds. Then it leaves once, with everyone inside. Debounce works the same way: each new call starts the quiet wait again.
- In real life: Each new person makes the doors wait again
- In JavaScript: Each new call starts the wait again
- In real life: The elevator leaves once, after the last person steps in
- In JavaScript: The function runs once, with the last call's arguments
The examples in this lesson use a fake clock, made by createFakeClock(). It works like real timers, but time only moves when you call clock.tick(ms). So every run prints the same thing. Each wrapper takes the clock as its last argument.
const clock = createFakeClock();
const search = debounce((term) => console.log("search:", term), 100, {}, clock);
search("c");
search("ca");
search("cat");
clock.tick(100);Line 2 makes one debounced wrapper called search, with a wait of 100 milliseconds. Lines 4 to 6 call it three times in a row. Each call replaces the saved search word. Line 7 moves the fake clock forward 100 milliseconds. The wait ends, and the real function runs once. It prints search: cat. Only the last word reaches the function.
- The wait starts again after each call. It does not count from the first call.
- A leading call runs right away, at the start of a wait. A trailing call runs at the end of the wait. With both turned on, the trailing call only runs if another call came in after the leading call.
maxWaitis the longest the function may wait while calls keep coming. It does not make every call run.
const clock = createFakeClock();
const fired = [];
const save = debounce((value) => fired.push(clock.now() + "ms " + value), 100, { leading: true, trailing: true }, clock);
save("draft");
clock.tick(40);
save("final");
clock.tick(100);
console.log(fired.join(" | "));Line 3 turns on both leading and trailing. Line 5 calls save("draft"). Leading is on, so it runs right away, at 0ms. Line 7 calls again at 40ms, with final. That call came during the wait, so a trailing call runs 100ms later, at 140ms. Line 9 prints 0ms draft | 140ms final.
Here is the full debounce. Its first two arguments are the real function, fn, and the wait in milliseconds. The last argument, clock, lets the tests pass in the fake clock. In a browser it uses the real Date.now, setTimeout, and clearTimeout.
function debounce(fn, wait, options = {}, clock = { now: Date.now, setTimeout, clearTimeout }) { const leading = options.leading === true; const trailing = options.trailing !== false; const maxing = typeof options.maxWait === "number"; const maxWait = maxing ? Math.max(options.maxWait, wait) : undefined; let timerId, maxTimerId, lastArgs, lastThis, result; function invoke() { const args = lastArgs; const receiver = lastThis; lastArgs = lastThis = undefined; result = fn.apply(receiver, args); return result; } function finishWait() { timerId = undefined; if (maxTimerId !== undefined) clock.clearTimeout(maxTimerId); maxTimerId = undefined; if (trailing && lastArgs) return invoke(); lastArgs = lastThis = undefined; return result; } function finishMaxWait() { maxTimerId = undefined; if (timerId !== undefined) clock.clearTimeout(timerId); timerId = undefined; if (lastArgs) return invoke(); return result; } function debounced(...args) { const callLeading = leading && timerId === undefined; lastArgs = args; lastThis = this; if (timerId !== undefined) clock.clearTimeout(timerId); timerId = clock.setTimeout(finishWait, wait); if (maxing && maxTimerId === undefined) maxTimerId = clock.setTimeout(finishMaxWait, maxWait); return callLeading ? invoke() : result; } debounced.cancel = () => { if (timerId !== undefined) clock.clearTimeout(timerId); if (maxTimerId !== undefined) clock.clearTimeout(maxTimerId); timerId = maxTimerId = lastArgs = lastThis = undefined; }; debounced.flush = () => { if (timerId === undefined && maxTimerId === undefined) return result; if (timerId !== undefined) clock.clearTimeout(timerId); if (maxTimerId !== undefined) clock.clearTimeout(maxTimerId); timerId = maxTimerId = undefined; return trailing && lastArgs ? invoke() : result; }; return debounced;}Here is what the main lines do:
- Lines 2 and 3 read the options.
leadingis off unless you turn it on.trailingis on unless you turn it off. - Lines 8 to 14 are
invoke(), which runs the real function. Line 12 usesfn.apply(receiver, args).applycalls a function with a chosenthisand a list of arguments. So a method still gets its own object asthis, and the function gets the newest arguments. - Lines 35 and 36 save the newest arguments and
thison every call. - Line 37 cancels the old timer, and line 38 starts a new one. This is the "wait starts again" rule.
- Line 39 starts a second timer for
maxWait. It starts only once per burst, which is a group of calls close together. This timer makes sure the function still runs while someone types without stopping. - Lines 43 and 48 add
cancel()andflush(). The section on real apps shows how to use them.
Trailing debounce waits until the calls stop, then runs once with the newest arguments.
script
const fired = [];const search = debounce((term) => { fired.push(clock.now() + "ms " + term);}, 100, { trailing: true }, clock); search("a");clock.tick(40);search("ab");clock.tick(40);search("abc");clock.tick(99);clock.tick(1);console.log(fired.join(" | "));In the first timeline, calls come at 0, 40, and 80 milliseconds. Later calls cancel the first two timers. The last timer is never canceled, so it ends at 180 milliseconds, and only abc runs. Pick the other choices to see leading calls and maxWait.
180ms, with abc.Throttle runs at a steady pace
INTERVALSUse throttle when work should keep happening while events keep coming, just not on every event. For example:
- a bar that shows how far down the page you have scrolled,
- a handler that moves something while you drag it,
- a button that may only act once per second,
- analytics that record what the user does, now and then.
The time period throttle waits between runs is called the interval. In the examples below it is 100 milliseconds.
function throttle(fn, interval, options = {}, clock = { now: Date.now, setTimeout, clearTimeout }) { const leading = options.leading !== false; const trailing = options.trailing !== false; let timerId, lastArgs, lastThis, lastInvokeTime, result; function invoke(time) { const args = lastArgs; const receiver = lastThis; lastArgs = lastThis = undefined; lastInvokeTime = time; result = fn.apply(receiver, args); return result; } function scheduleTrailing(remaining) { if (!trailing || timerId !== undefined) return; timerId = clock.setTimeout(() => { timerId = undefined; if (lastArgs) invoke(clock.now()); }, remaining); } function throttled(...args) { const time = clock.now(); lastArgs = args; lastThis = this; if (lastInvokeTime === undefined) { lastInvokeTime = time; return leading ? invoke(time) : (scheduleTrailing(interval), result); } const remaining = interval - (time - lastInvokeTime); if (remaining <= 0) { if (timerId !== undefined) clock.clearTimeout(timerId); timerId = undefined; return invoke(time); } scheduleTrailing(remaining); if (!trailing) lastArgs = lastThis = undefined; return result; } throttled.cancel = () => { if (timerId !== undefined) clock.clearTimeout(timerId); timerId = lastArgs = lastThis = lastInvokeTime = undefined; }; throttled.flush = () => { if (timerId === undefined) return result; clock.clearTimeout(timerId); timerId = undefined; return lastArgs ? invoke(clock.now()) : result; }; return throttled;}Throttle remembers when the real function last ran, in lastInvokeTime (line 4). On each call:
- Lines 25 and 26 save the newest arguments and
this. - Line 31 works out how much of the interval is left.
- If no time is left (line 32), the function runs now (line 35).
- If some time is left, line 37 starts one timer for the time that is left. It does this only when trailing is on and no timer is waiting yet.
Later calls during the interval only replace the saved arguments. So when the timer ends, the function runs with the newest event data.
const clock = createFakeClock();
const positions = [];
const trackScroll = throttle((position) => positions.push(position), 100, {}, clock);
trackScroll(10);
clock.tick(20);
trackScroll(80);
clock.tick(80);
console.log(positions.join(" -> "));Line 5 sends position 10. It is the first call, so it runs right away. Line 7 sends 80 at 20ms. That is inside the 100ms interval, so it waits. Line 8 moves the clock to 100ms. The interval ends, and the saved 80 runs. Line 9 prints 10 -> 80.
- Set
leading: falsewhen even the first event should wait for a full interval. - Set
trailing: falsewhen a value that came in during the interval is not worth running later. - Make the wrapper once and keep it. The interval is stored in the wrapper, not in the event listener.
Throttle runs at a steady pace while calls keep coming. Leading and trailing decide whether it runs at the start or the end of each interval.
script
const fired = [];const track = throttle((position) => { fired.push(clock.now() + "ms " + position);}, 100, { leading: true, trailing: true }, clock); track("top");clock.tick(20);track("middle");clock.tick(50);track("bottom");clock.tick(30);clock.tick(20);track("after");clock.tick(80);console.log(fired.join(" | "));See how this differs from debounce. While the calls keep coming, the throttled function runs at 0ms, 100ms, and 200ms. It does not wait for the calls to stop. It runs at a steady pace.
bottom runs at 100ms and middle never runs.Throttle drawing work with requestAnimationFrame
ONCE PER FRAMEA time throttle works well when the interval is a rule you choose, like "at most once per second". Work that changes what the user sees is different. The browser draws the page in frames, often about 60 times per second. It does not draw on a 100ms schedule.
requestAnimationFrame(callback) asks the browser to run callback just before it draws the next frame. rAF throttle uses it like this:
- On the first call, ask the browser for one frame.
- On later calls before that frame, only replace the saved arguments.
- When the frame comes, run the real function once, with the newest arguments.
Use it for a preview that follows the pointer, for drawing on a canvas, or for moving things as the page scrolls.
function rafThrottle(fn, raf = { requestAnimationFrame, cancelAnimationFrame }) { let frameId; let lastArgs; let lastThis; let result; function invoke() { const args = lastArgs; const receiver = lastThis; frameId = lastArgs = lastThis = undefined; result = fn.apply(receiver, args); return result; } function throttled(...args) { lastArgs = args; lastThis = this; if (frameId === undefined) frameId = raf.requestAnimationFrame(invoke); return result; } throttled.cancel = () => { if (frameId !== undefined) raf.cancelAnimationFrame(frameId); frameId = lastArgs = lastThis = undefined; }; throttled.flush = () => frameId === undefined ? result : (raf.cancelAnimationFrame(frameId), invoke()); return throttled;}Line 18 asks for one frame, but only if no frame is waiting yet. Lines 16 and 17 save the newest arguments on every call. Line 23 cancels a frame that has not run yet, with cancelAnimationFrame. That matters when a component is removed from the page or a Reset button clears everything.
The next example passes in a fake frame clock, frameClock. Instead of waiting for the browser, it saves each frame callback in the frames list.
const frames = [];
const frameClock = {
requestAnimationFrame(callback) {
frames.push(callback);
return frames.length;
},
cancelAnimationFrame() {},
};
const panel = { width: 0 };
const resizePanel = rafThrottle((width) => {
panel.width = width;
}, frameClock);
resizePanel(800);
resizePanel(900);
frames.shift()();
console.log(panel.width);Lines 14 and 15 send two widths, 800 and 900, before any frame runs. Only one frame was asked for. Line 16 runs that frame by hand. It uses the latest width, so line 17 prints 900. In a real browser, the frame runs just before the next drawing.
900.- For analytics or limits on network requests, use a time throttle. A frame is not a fixed number of milliseconds.
- If resize work only matters after the window stops changing size, use debounce.
- Browsers may pause frames in hidden tabs. So don't use rAF for work that must always happen.
Sometimes the best fix is to need no handler at all. CSS can do some jobs by itself:
position: stickykeeps an element stuck in place while the page scrolls.- CSS transitions animate a change.
- Scroll-driven animations move things as the page scrolls.
content-visibilitylets the browser skip drawing parts that are off screen.
If you do listen to scroll or touch events and never call preventDefault(), add { passive: true } to the listener. A passive listener promises not to stop scrolling, so the browser can scroll without waiting for your code.
Real apps: libraries, cleanup, and requests
REAL APPSMany apps use Lodash, a popular library of helper functions, instead of writing their own. Its _.debounce and _.throttle work like the versions in this lesson:
- They have
leadingandtrailingoptions. Debounce also hasmaxWait. - The real function gets the arguments from the last call.
- The wrapper has
.cancel()and.flush().
Lodash even builds its throttle out of its debounce, with maxWait set to the same time as the wait. That shows how close the two ideas are.
Each row of the next table is one thing to get right in a real app: what to do, and why it matters.
| Topic | What to do | Why it matters |
|---|---|---|
| Lodash | Lodash is a popular helper library. Its _.debounce and _.throttle have leading, trailing, .cancel(), and .flush(). | Both run with the arguments from the last call. Its throttle is built from its debounce, with maxWait set to wait. |
| Passive listeners | Add { passive: true } to scroll and touch listeners that never call preventDefault(). | The browser can scroll without waiting for your handler. |
| Stop old requests | Debouncing a search does not stop older requests that already started. | Use AbortController to stop them, or ignore answers that are out of date. |
| Clean up | Call .cancel() and stop requests when a component or page goes away. | Waiting work can't change a page that is gone, and it doesn't hold on to memory. |
A search box often sends a request with fetch. An AbortController can stop a request that has already started. You pass its signal to fetch, and later call abort() on the controller.
let currentRequest;const searchLater = debounce(async (term) => { currentRequest?.abort(); currentRequest = new AbortController(); const response = await fetch("/api/search?q=" + encodeURIComponent(term), { signal: currentRequest.signal, }); renderResults(await response.json());}, 250); input.addEventListener("input", (event) => searchLater(event.target.value));window.addEventListener("beforeunload", () => { searchLater.cancel(); currentRequest?.abort();});- Line 2 makes a debounced search that waits 250 milliseconds.
- Line 3 stops the previous request, if there is one.
- Line 4 makes a new controller for this request, and line 6 links the request to it.
- Lines 13 and 14 run when the page is closing. Line 13 cancels a search that is still waiting. Line 14 stops a request that has already started.
The wrapper has two methods for waiting work. cancel() throws away the waiting call. flush() runs the waiting call right now, instead of waiting.
const clock = createFakeClock();
const saved = [];
const save = debounce((value) => saved.push(value), 100, {}, clock);
save("old");
save.cancel();
clock.tick(100);
save("new");
save.flush();
console.log(saved.join(" | "));Line 6 cancels the save of old, so it never runs. Line 7 moves the clock, and nothing happens. Line 8 saves new. Line 9 flushes it, so it runs right away. Line 10 prints new.
Debounce only controls when a request starts. It does not control the order the answers come back in. An older, slower request can still answer last. So stop the old request, or give each request a number and ignore answers to old numbers. The cancellation lesson covers AbortController in depth. The memory management lesson explains why to clean up when a component is removed from the page.
Common bugs and mix-ups
DEBUGGING- Making the wrapper inside the event handler. The timer is stored in the wrapper. A new wrapper has a new timer, so it can't cancel the timer from the last event.
- Losing
this. If the real function is a method, call it withfn.apply(thisArg, args). A plainfn(...args)loses the object the method belongs to. - Using old arguments. The trailing call should use the newest event data, not the arguments from the first call.
- Forgetting cleanup. Waiting timers, frames, and requests can still change a component after it is removed from the page. Cancel them when it is removed.
- Thinking debounce keeps answers in order. Debounce delays when requests start. Answers from the network can still come back in any order.
- Calling
flush()during cleanup.flush()runs the waiting call on purpose. Cleanup usually needscancel()instead. - Thinking a passive listener is a throttle. A passive listener helps scrolling stay smooth. It does not make the handler run less often.
// Bug: this creates a new wrapper for every event.input.addEventListener("input", (event) => { debounce(() => search(event.target.value), 300)();}); // Fix: create one wrapper and reuse it.const debouncedSearch = debounce((value) => search(value), 300);input.addEventListener("input", (event) => { debouncedSearch(event.target.value);});Line 3 makes a new wrapper on every input event and calls it right away. Each wrapper has its own timer, so every key press still searches. Line 7 makes one wrapper. Line 9 calls that same wrapper on every event, so all the calls share one timer.
The table below sums up each option. Read a row to see when the function runs and where the option helps.
| Option | When the function runs | Where it helps |
|---|---|---|
| Trailing debounce | Nothing runs right away. It runs once after the calls stop. | The usual choice for search and autosave, so only the final value is sent. |
| Leading debounce | It runs right away. It runs once more at the end only if more calls came in. | Quick feedback at the start, plus a final update at the end. |
| Leading throttle | The first call in an interval runs right away. | When the page must react at once. |
| Trailing throttle | The newest saved call runs when the interval ends. | When the final position matters. |
maxWait | Debounce runs even while the calls never stop. | So autosave or syncing never waits forever. |
Choose the right limiter
SORTERChoose by how often the user needs to see a change:
- Only the final value, after a pause? Use debounce.
- Regular updates while events keep coming? Use throttle.
- Changes to what is drawn on the screen? Use rAF throttle.
- Can CSS do the job? Then don't use JavaScript for it at all.
- Send a search request after the user stops typing
- Redraw a chart after the user stops resizing the window
- Record how far the user scrolled, at a steady pace
- Limit drag handler work while still updating during the drag
- Move a preview that follows the pointer
- Move an element based on the scroll position
Keep a header stuck to the top while scrollingSkip drawing content that is far below the screen
Put each task under the tool that fits how often the user needs to see a change.
Practice exercises
5 TASKSCalls come at 0, 40, and 80 milliseconds. Predict what the log prints.
const clock = createFakeClock();
const log = [];
const search = debounce((value) => log.push(clock.now() + "ms " + value), 100, {}, clock);
search("a");
clock.tick(40);
search("ab");
clock.tick(40);
search("abc");
clock.tick(100);
console.log(log.join(" | "));Later calls cancel the first two timers. The third timer ends, so the only output is 180ms abc.
Both leading and trailing are on. Predict which values run, and when.
const clock = createFakeClock();
const log = [];
const save = debounce((value) => log.push(clock.now() + "ms " + value), 100, { leading: true, trailing: true }, clock);
save("a");
clock.tick(40);
save("ab");
clock.tick(100);
console.log(log.join(" | "));The output is 0ms a | 140ms ab. a runs at the start. ab runs 100ms after the second call.
Predict what the throttled log holds when the clock reaches 100ms.
const clock = createFakeClock();
const log = [];
const track = throttle((value) => log.push(clock.now() + "ms " + value), 100, { leading: true, trailing: true }, clock);
track("top");
clock.tick(20);
track("middle");
clock.tick(50);
track("bottom");
clock.tick(30);
console.log(log.join(" | "));By 100ms the output is 0ms top | 100ms bottom. The trailing call uses the newest saved position.
When the component is removed, the waiting save should not run. Which method should cleanup() call?
function saveDraft() {
console.log("saved");
}
const debounced = debounce(saveDraft, 500);
function cleanup() {
// stop the waiting save when the component is removed
}function saveDraft() {
console.log("saved");
}
const debounced = debounce(saveDraft, 500);
function cleanup() {
debounced.cancel();
}.cancel() clears the waiting timer and forgets the saved arguments. So nothing changes the removed component later.
The calls never pause for 100ms. Predict what maxWait makes run, and when.
const clock = createFakeClock();
const log = [];
const sync = debounce((value) => log.push(clock.now() + "ms " + value), 100, { maxWait: 250 }, clock);
sync("a");
clock.tick(80);
sync("b");
clock.tick(80);
sync("c");
clock.tick(80);
sync("d");
clock.tick(10);
console.log(log.join(" | "));The output is 250ms d. maxWait runs the function with the newest value, even though the calls never paused for 100ms.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence best describes debounce?
Choose an answer to see the explanation.
Question 2 of 7Which sentence best describes throttle?
Choose an answer to see the explanation.
Question 3 of 7What does the trailing debounce timeline print?
Read the code, then predictconst clock = createFakeClock(); const log = []; const search = debounce((value) => log.push(clock.now() + "ms " + value), 100, {}, clock); search("a"); clock.tick(40); search("ab"); clock.tick(40); search("abc"); clock.tick(100); console.log(log.join(" | "));Choose an answer to see the explanation.
Question 4 of 7With both
leadingandtrailingtrue, when does the trailing call happen?Read the code, then predictconst clock = createFakeClock(); const log = []; const save = debounce((value) => log.push(clock.now() + "ms " + value), 100, { leading: true, trailing: true }, clock); save("a"); clock.tick(40); save("ab"); clock.tick(100); console.log(log.join(" | "));Choose an answer to see the explanation.
Question 5 of 7What does
maxWaitadd to debounce?Read the code, then predictconst clock = createFakeClock(); const log = []; const sync = debounce((value) => log.push(clock.now() + "ms " + value), 100, { maxWait: 250 }, clock); sync("a"); clock.tick(80); sync("b"); clock.tick(80); sync("c"); clock.tick(80); sync("d"); clock.tick(10); console.log(log.join(" | "));Choose an answer to see the explanation.
Question 6 of 7Which job fits requestAnimationFrame throttle best?
Choose an answer to see the explanation.
Question 7 of 7Which cleanup belongs with a debounced search request?
Choose an answer to see the explanation.
Key takeaways
- Debounce waits until the calls stop, then runs once with the arguments from the last call.
- Throttle runs at most once per interval. With trailing on, it also runs the newest saved call at the end of the interval.
- A leading call runs at the start of a wait, and a trailing call runs at the end.
maxWaitmakes debounce run even while calls never stop. - rAF throttle is best for work that changes what is drawn, because it runs once per frame.
- Make each wrapper once, keep
thisand the arguments, cancel waiting work when you clean up, and stop old requests.
Next, the Performance stage moves on to DOM and rendering performance. You will learn how the browser lays out and draws the page, and how to avoid work it doesn't need to do.