Fast event handlers
Make clicks, scrolls and key presses answer at once: show the change first, save and send analytics later, mark listeners passive, and debounce slow searches.
- 01Answer an input firstChange what the visitor is waiting for, yield so a frame can show it, then do the rest of the handler's work.
- 02Move quiet work out of the wayRun saving and analytics in idle time, finish them when the page is hidden, and know that localStorage blocks.
- 03Keep scrolling and typing smoothMark scroll listeners passive, and debounce slow work that runs on every key press.
Why does Asha's cart wait for the analytics?
Asha is back on the Leaf and Kettle tea list, on her phone. She clicks Add on Mint tea. For a quarter of a second, nothing changes. Then two things appear at once. The badge at the top says Cart: 1. And a line under the search box says which teas go well with Mint tea.
Ravi built that Add button. When Asha clicks it, his code does four jobs:
- Show the badge: change Cart: 0 to Cart: 1.
- Show suggestions: pick teas that go well with Mint tea, and show them.
- Save the cart: keep it in the browser, so it's still there tomorrow.
- Send analytics: tell the shop's stats service that someone added Mint tea.
Asha is only waiting for the first one. She wants to know that her click worked. The other three can come a moment later, or when she isn't looking. So why did the badge wait for all of them? And how can it come first, without dropping the rest?
Ravi also built a screen-first page. It does the same four jobs, in a different order. Here is what Asha saw on each page. These are real frames Chrome captured. A frame is one picture the browser draws on the screen.
Here are the same times as code:
// Times in ms after Asha's click, on her phone.const pages = [ { name: "All at once", cartShown: 252.8, suggestionsShown: 252.8 }, { name: "Screen first", cartShown: 44.9, suggestionsShown: 115.9 },];for (const page of pages) { console.log(`${page.name}: Cart: 1 after ${page.cartShown.toFixed(1)} ms, the suggestions after ${page.suggestionsShown.toFixed(1)} ms`);}- Line 1 is a comment. Times are in milliseconds (ms) after her click. A millisecond is a thousandth of a second.
- Lines 3 and 4 hold the two pages.
cartShownis the first frame with Cart: 1.suggestionsShownis the first frame with the suggestions. - Line 7 prints
All at once: Cart: 1 after 252.8 ms, the suggestions after 252.8 msandScreen first: Cart: 1 after 44.9 ms, the suggestions after 115.9 ms.
It was the same work on the same phone. But on the screen-first page, Cart: 1 came 207.9 ms sooner. Why it matters: Asha judges the page by the first change she sees, not by when all the work is done.
You order a mint tea at a café. The waiter could write it in the order book, update the stock list, and only then bring your tea. A good waiter brings the tea first. The paperwork can wait until you're sipping.
- In real life: Your order
- In the browser: Asha's click on Add
- In real life: Bringing the tea to your table
- In the browser: Showing Cart: 1 in the next frame
- In real life: Writing the order in the book
- In the browser: Saving the cart
- In real life: Updating the stock list for head office
- In the browser: Sending analytics
- In real life: Doing the paperwork once you have your tea
- In the browser: Running the other jobs after the frame
In an event handler, first change what the visitor is waiting to see. Then give the browser a moment to draw it. Do the rest after: what she can see a moment later, and what she can't see when the page is quiet. Two more fixes keep scrolling and typing smooth: passive listeners and debouncing.
An event handler is the code that runs when an event happens, like a click. Lesson 26.2 split a click's time into three parts. The handlers' part is called processing duration. This lesson makes that part short, in four steps:
- Show the change first, then yield.
- Move saving and analytics to quiet moments.
- Let scrolling start without waiting for your listener.
- Wait for the typing to stop before a slow search.
How the course recorded it
- Ten pages: nine are the tea list from lesson 26.1, with one new script on each. Four change the Add button, three add a scroll listener, and two change the search box. The tenth times localStorage writes.
- Asha's phone: this computer in Chrome 157, with its processor, the CPU, slowed 4 times, as in Module 27.
- Stand-in work: each slow job runs a loop called
work()for a set number of steps. It stands in for real code that takes time. - Three runs each: each page loaded three times, taking turns. The lesson quotes the middle one. Each time, Chrome kept a trace: a detailed record of everything it did, with screenshots.
- Input: clicks and key presses went through Chrome's DevTools protocol, the way programs drive Chrome. Chrome passes them straight to the page, as it does a real mouse or keyboard. Wheel turns are different. The scrolling section says how.
Show the change, then yield
Here is Ravi's click handler on the first page:
teas.addEventListener("click", function addToCart(event) { if (!event.target.matches(".add")) return; const tea = event.target.closest("li").dataset.name; cart.push(tea); showBadge(); showSuggestions(tea); saveCart(); sendAnalytics(tea);});- Line 1 adds a click listener to the list of teas,
teas. A listener is a function you add withaddEventListener. This one runs on every click in the list. - Line 2 stops if the click wasn't on an Add button.
- Line 3 reads the tea's name from its row. Line 4 puts it in the cart.
- Lines 5 to 8 do the four jobs, one after another.
And here are the four jobs:
const cart = [];function showBadge() { document.querySelector("#cart").textContent = "Cart: " + cart.length;}function showSuggestions(tea) { work(14_000_000); // picks teas that go well with this one document.querySelector("#about").textContent = "Goes well with " + tea + ": Jasmine and Green tea.";}function saveCart() { work(10_000_000); // packs the cart with every tea's details localStorage.setItem("cart", JSON.stringify(cart));}function sendAnalytics(tea) { work(24_000_000); // the analytics library builds its report navigator.sendBeacon("/collect", JSON.stringify({ event: "add_to_cart", tea }));}- Lines 2 to 4 change the badge. That's quick.
- Lines 5 to 8 pick the suggestions and show them. Line 6 is the stand-in work: 14 million steps.
- Lines 9 to 12 save the cart. Line 11 writes it to
localStorage, a small store the browser keeps for each site. - Lines 13 to 16 send analytics. Line 15 hands a small message to
navigator.sendBeacon(), which sends it to the server.
Why the badge waited
Line 3 changed the badge almost at once. So why didn't Asha see it?
// The all-at-once click, in ms after Asha's click.const click = { cartWritten: 4.8, handlersRan: 210.1, cartShown: 252.8 };console.log(`Cart: 1 was written after ${click.cartWritten.toFixed(1)} ms.`);console.log(`The click's handlers ran for ${click.handlersRan.toFixed(1)} ms.`);console.log(`Cart: 1 reached the screen after ${click.cartShown.toFixed(1)} ms.`);- Line 2 holds three times from the trace.
- It prints
Cart: 1 was written after 4.8 ms., thenThe click's handlers ran for 210.1 ms.andCart: 1 reached the screen after 252.8 ms.
The badge's text changed at 4.8 ms. But changing the page isn't the same as showing it. The browser shows changes only when it draws a frame. And it draws frames between tasks, never in the middle of one.
A task is one piece of work that runs from start to end, as lesson 27.1 showed. Tasks run on the main thread. That's the one thread that runs the page's JavaScript and draws its frames.
Ravi's whole handler was one task. So the frame waited for all four jobs: 210.1 ms. Then Chrome needed about 38 ms more to put the frame on the screen. Chrome timed the whole click at 250.8 ms. That's over 200 ms, the limit for a good INP. INP, or Interaction to Next Paint, is the measure from lesson 26.1: roughly a visit's slowest click, tap or key press. Why it matters: one slow job in a handler holds back every change the handler makes.
Yield right after the change
web.dev's advice for event handlers starts simply: do as little work in them as you can. Then it names a better place to yield. In its words, yield "immediately after an event callback that updates the user interface so rendering logic can run sooner." Rendering is drawing the page. To yield is to stop for a moment, so the browser can do something else. Lesson 27.2 showed how scheduler.yield() does it. Here is the second page's handler:
teas.addEventListener("click", async function addToCart(event) { if (!event.target.matches(".add")) return; const tea = event.target.closest("li").dataset.name; cart.push(tea); showBadge(); await scheduler.yield(); showSuggestions(tea); saveCart(); sendAnalytics(tea);});- Line 1 now says
async, so the function can pause withawait. - Line 5 changes the badge, as before.
- Line 6 yields. The rest of the function waits for a new task.
- Lines 7 to 9 do the other three jobs, in that new task.
Here are both clicks, side by side:
// In ms after Asha's click, on her phone.const pages = [ { name: "All at once", interaction: 250.8, cartShown: 252.8, suggestionsShown: 252.8 }, { name: "Then yield", interaction: 43.9, cartShown: 46.0, suggestionsShown: 257.3 },];for (const page of pages) { console.log(`${page.name}: the click took ${page.interaction.toFixed(1)} ms. Cart: 1 at ${page.cartShown.toFixed(1)}, suggestions at ${page.suggestionsShown.toFixed(1)}.`);}- Lines 3 and 4 hold each page's click.
- Line 7 prints
All at once: the click took 250.8 ms. Cart: 1 at 252.8, suggestions at 252.8.andThen yield: the click took 43.9 ms. Cart: 1 at 46.0, suggestions at 257.3.
Cart: 1 came 206.8 ms sooner. Chrome timed the click at 43.9 ms, well under 200. Yet the suggestions came no sooner. They still waited for saving and analytics, in the same task. The next section fixes that. Why it matters: one line made the click's answer come more than five times sooner.
Why the frame went first
After a yield, the browser picks what to run next. Lesson 27.2 found that scheduler.yield() usually lets your next piece go ahead of other tasks. So why did the frame win here? Chrome's scheduler treats the frame after a click as urgent. A comment in its source code says: "The user is waiting for the result of a discrete input event, e.g. clicks or typing. The next frame is prioritized at highest priority (matching input)."
A discrete input is a single event, like a click or a key press. A scroll is the other kind: a stream of events. The trace agrees with the comment. The three jobs started 43.5 ms after the click, right after the main thread finished the badge's frame. Here is the main thread on all four pages:
Read each row from left to right. On the first page, the jobs start at once and the frame waits. On the other three, the badge gets its frame first. Why it matters: in Chrome, a yield right after the change is enough to get it on the screen.
When there's no scheduler.yield()
Safari has no scheduler.yield(). Lesson 27.3 let a frame through another way, with requestAnimationFrame and setTimeout. web.dev's own example uses the same trick:
const afterNextFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));const yieldToFrame = () => (globalThis.scheduler?.yield ? scheduler.yield() : afterNextFrame());- Line 1 is
afterNextFramefrom lesson 27.3.requestAnimationFrameruns its callback just before the next frame. The timer set there starts a new task after that frame. - Line 2 uses
scheduler.yield()where the browser has it, andafterNextFrame()where it doesn't.
The course's third page used line 1. It awaited afterNextFrame() where the second page yielded. Chrome timed its click at 44.1 ms, and Cart: 1 came after 46.2 ms. Why it matters: every browser can show the change first.
Look at the diagram once more. After the yield, the three jobs still ran as one long task of 208.3 ms. A second click in that time would wait. Lesson 27.3 showed how to cut such work into short pieces. Pick a page below to see when each job ran:
showSuggestions()started at 5.7 ms, ran 65.8 mssaveCart()started at 71.5 ms, ran 42.2 mssendAnalytics()started at 113.7 ms, ran 99.3 ms
Its handlers ran for 210.1 ms. Cart: 1 reached Asha's screen after 252.8 ms, and the suggestions after 252.8 ms.
Saving and analytics can wait for a quiet moment
On the yield page, the suggestions came after 257.3 ms. Asha can see them. But they waited behind saving and analytics, which she can't. So the fourth page sorts the jobs by who is waiting for them:
const whenIdle = (job) => requestIdleCallback(job, { timeout: 1000 });teas.addEventListener("click", async function addToCart(event) { if (!event.target.matches(".add")) return; const tea = event.target.closest("li").dataset.name; cart.push(tea); showBadge(); whenIdle(saveCart); whenIdle(() => sendAnalytics(tea)); await scheduler.yield(); showSuggestions(tea);});- Line 1 makes
whenIdle. It asks the browser to run a job in an idle period: a stretch when the main thread has nothing else to do. Lesson 27.2 metrequestIdleCallback(). Thetimeoutsays: if no idle period comes within 1 second, run the job anyway. - Line 6 changes the badge.
- Lines 7 and 8 ask for
saveCartandsendAnalyticsin idle time. They don't run yet. - Line 9 yields, so the badge's frame can be drawn.
- Line 10 shows the suggestions, in the next task.
When a customer pays, Meera hands over the tea at once. She writes the sale in the ledger later, when nobody is waiting. If the shop stays busy all day, she still does it before she locks up.
- In real life: Handing over the tea
- In the browser: Cart: 1, then the suggestions
- In real life: Writing the sale in the ledger
- In the browser: saveCart() and sendAnalytics()
- In real life: A moment with no customers
- In the browser: An idle period
- In real life: Before she locks up, busy or not
- In the browser: The timeout, and saving when the page is hidden
// The screen-first click, in ms after Asha's click.const times = { suggestionsShown: 115.9, saveStarted: 113.0, saveRan: 42.4, analyticsStarted: 157.4, analyticsRan: 98.8 };console.log(`The suggestions reached the screen after ${times.suggestionsShown.toFixed(1)} ms.`);console.log(`saveCart started at ${times.saveStarted.toFixed(1)} ms and ran ${times.saveRan.toFixed(1)} ms.`);console.log(`sendAnalytics started at ${times.analyticsStarted.toFixed(1)} ms and ran ${times.analyticsRan.toFixed(1)} ms.`);- Line 2 holds five times from the trace.
- It prints
The suggestions reached the screen after 115.9 ms., thensaveCart started at 113.0 ms and ran 42.4 ms.andsendAnalytics started at 157.4 ms and ran 98.8 ms.
The suggestions came 141.4 ms sooner than on the yield page. The two quiet jobs ran right after them. saveCart started just before the frame reached the screen. That's because the main thread had already done its part of that frame. The idle callback standard says idle time comes after "rendering and compositing for a given frame has been completed". Why it matters: work Asha can't see now fills the gaps she doesn't notice.
localStorage blocks while it writes
Why move saving at all? Because localStorage is synchronous: the next line of code waits until the write is done. MDN says the work is done "synchronously, blocking the execution of other JavaScript code until the operation is completed." web.dev puts it more strongly: "LocalStorage should be avoided because it is synchronous and will block the main thread."
The course timed writes of three sizes on Asha's phone:
// One localStorage.setItem() call on Asha's phone, on average, in ms.const writes = [ { size: "1 KB", ms: 0.0 }, { size: "100 KB", ms: 0.4 }, { size: "1 MB", ms: 5.2 },];for (const write of writes) { console.log(`${write.size}: ${write.ms < 0.1 ? "under 0.1" : write.ms.toFixed(1)} ms`);}- Lines 3 to 5 hold the average time of one
setItem()call. The texts were 1,024 letters, 102,400 letters, and about a million letters long. - It prints
1 KB: under 0.1 ms,100 KB: 0.4 msand1 MB: 5.2 ms.
A small cart costs almost nothing. On Ravi's pages, the write itself took 1.4 ms or less. The stand-in work made saveCart slow. But a big write blocks the page, and it grows with the size. Why it matters: keep what you store small, and write it when the page is quiet. For bigger data, MDN points to IndexedDB, another store in the browser, which doesn't block. Try writes in your own browser:
const text = "x".repeat(size);const start = performance.now();localStorage.setItem("cf-storage-lab", text);const took = performance.now() - start;Nothing written yet.
Pick a size. The lab writes a text of that many letters to localStorage five times, and times each write.
Analytics, without the wait
navigator.sendBeacon() doesn't wait for the server. MDN says it "asynchronously sends an HTTP POST request containing a small amount of data". It's made for analytics. On Asha's phone, a call took 0.4 ms on average. So the slow part of analytics is the code that builds the report. Here, that's the stand-in work.
Waiting for quiet time has one risk. If Asha closes the tab first, the work might never run. MDN's advice for analytics is to send it when the page is hidden, on the visibilitychange event. The cart deserves the same care:
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") saveCart();});- Line 1 listens for
visibilitychange. It fires when the page is hidden or shown again, for example when Asha switches tabs. - Line 2 saves the cart if the page is now hidden.
Lesson 27.3's IdleQueue does this for you, with ensureTasksRun. Why it matters: quiet work still has to happen. It just doesn't go first.
Passive listeners let the page scroll
Ravi wants to know how far people scroll down the tea list. So he adds track.js. It listens for the wheel event. A mouse wheel or a trackpad fires it when you scroll.
let scrolled = 0;function noteScroll(event) { work(23_000_000); // works out which teas Asha scrolled past scrolled += event.deltaY;}teas.addEventListener("wheel", noteScroll);- Lines 2 to 5 are
noteScroll. Line 3 stands in for slow tracking code: 23 million steps, about 100 ms on Asha's phone. - Line 6 adds it as a wheel listener on the tea list.
Asha turns her mouse wheel over the list. Nothing moves for a tenth of a second. Then the list jumps. Why would a listener that only takes notes hold the scroll back?
A wheel event has a default action: the thing the browser does unless a listener stops it. Here, that's scrolling. A listener can stop it with event.preventDefault(). A map might do that, to zoom instead. The browser can't know if this listener will. MDN says the browser "is in general unable to start the default action until the event listener has finished".
A guard stands at the museum door. If he might stop people to check their bags, everyone waits while he decides. But this guard only counts visitors. Once he says so, people walk straight in, and he clicks his counter as they pass.
- In real life: Walking into the museum
- In the browser: Scrolling, the wheel event's default action
- In real life: A guard who might stop you
- In the browser: A listener that might call preventDefault()
- In real life: "I only count people"
- In the browser: { passive: true }
- In real life: Clicking the counter as you walk past
- In the browser: The listener still runs, but the scroll doesn't wait
That promise is called passive. The DOM standard, which defines events, says passive "indicates that the callback will not cancel the event by invoking preventDefault()". Here are the lines on the two other scroll pages:
teas.addEventListener("wheel", noteScroll, { passive: true });
window.addEventListener("wheel", noteScroll);- Line 1 adds the same listener to the list, marked passive.
- Line 2 adds it to the window, with no options at all.
// In ms after the wheel reached the page, on Asha's phone.const pages = [ { name: "On the list", moved: 118.9, listenerRan: 103.1 }, { name: "Passive", moved: 3.7, listenerRan: 103.3 }, { name: "On the window", moved: 3.6, listenerRan: 104.2 },];for (const page of pages) { console.log(`${page.name}: the list moved after ${page.moved.toFixed(1)} ms. The listener ran ${page.listenerRan.toFixed(1)} ms.`);}- Lines 3 to 5 hold each page's times.
movedis when the first frame showed the list moved, counted from when the wheel reached the page.listenerRanis how longnoteScrollran. - It prints
On the list: the list moved after 118.9 ms. The listener ran 103.1 ms., thenPassive: the list moved after 3.7 ms. The listener ran 103.3 ms.andOn the window: the list moved after 3.6 ms. The listener ran 104.2 ms.
On the list, the scroll waited for the whole listener. Passive, the list moved in the very next frame. The listener still ran for 103.3 ms, but nobody waited for it. Why it matters: a passive listener can be slow without making scrolling slow. It still keeps the main thread busy, so keep it light.
Why the window's listener didn't block
Line 2 didn't say passive, yet it acted like line 1. Many sites added these listeners to the whole page and never stopped a scroll. So browsers changed the default, and the DOM standard now says it. For touchstart, touchmove, wheel and mousewheel listeners on the window, the document, the html element or the body, passive is true unless you say otherwise.
Chrome did this for touch events in Chrome 56, and for wheel events in Chrome 73. Safari doesn't do it for wheel events. So write { passive: true } yourself.
The events were different inside, too. On the list, event.cancelable was true. On the passive pages, it was false. When every listener is passive, the DOM standard lets the browser start scrolling at once. It marks the event as one that can't be cancelled. What if a passive listener tries anyway?
const list = new EventTarget();list.addEventListener("wheel", (event) => { event.preventDefault(); console.log("Stopped by a passive listener?", event.defaultPrevented);}, { passive: true });list.dispatchEvent(new Event("wheel", { cancelable: true }));- Line 1 makes an
EventTarget: any object that can have listeners. Every element on a page is one. - Lines 2 to 5 add a passive wheel listener. Line 3 calls
preventDefault(). - Line 6 fires a wheel event that could be cancelled.
- Line 4 prints
Stopped by a passive listener? false.
The DOM standard says preventDefault() does nothing in a passive listener. Chrome may also print a warning in the console. To really stop a scroll, write { passive: false }. For touch, Chrome's team also points to the CSS touch-action property. The plain scroll event can't be cancelled at all, so passive doesn't matter for it.
How the wheel was recorded
The course turned the wheel through Chrome's DevTools protocol. DevTools holds a wheel event until the page has drawn a frame, and only then passes it on. Its source code says this is so that "newly added event listeners on the main thread" see it. A real mouse isn't held. Here the page was quiet, so the hold took 2.0 ms or less. The times above start when the wheel reached the page, after the hold.
Scrolling isn't part of INP, as lesson 26.1 showed. But a list that sticks still feels slow. Try it here:
box.addEventListener("wheel", () => { busyFor(60); // a slow listener wheels += 1;}, { passive });- Mint tea£4.50
- Green tea£4.00
- Earl Grey£3.80
- Chamomile£3.50
- Jasmine£4.20
- Masala chai£4.60
- Oolong£5.10
- Rooibos£3.70
- Assam£3.90
- Darjeeling£5.40
- Ceylon£3.60
- English breakfast£3.40
- Lady Grey£3.90
- Lapsang souchong£4.80
- Keemun£4.70
- Yunnan gold£5.60
- Sencha£4.30
- Matcha£6.80
- Genmaicha£3.80
- Hojicha£4.10
- Gyokuro£6.20
- Dragon well£5.30
- Gunpowder£3.70
- White peony£5.20
- Silver needle£6.50
- Pu-erh£5.90
- Tieguanyin£5.50
- Hibiscus£3.60
- Lemongrass£3.50
- Ginger£3.40
- Peppermint£3.30
- Lemon verbena£4.40
- Nettle£3.20
- Fennel£3.30
- Rosehip£3.60
- Elderflower£3.80
- Lavender£3.90
- Honeybush£3.70
- Yerba mate£4.20
- Turmeric£3.90
Scroll inside the list with a mouse wheel, a trackpad or a finger. Each event runs a 60 ms listener. Then tick passive and scroll again.
Search when Asha stops typing
The tea list has a search box. Ravi's search.js searches the whole shop on every key press:
function search(words) { work(35_000_000); // looks through the whole shop let found = 0; for (const row of teas.children) { row.hidden = !row.dataset.name.toLowerCase().includes(words); if (!row.hidden) found += 1; } document.querySelector("#about").textContent = "Teas found: " + found;}box.addEventListener("input", () => search(box.value.toLowerCase()));- Lines 1 to 9 are
search(). Line 2 stands in for searching the whole shop: 35 million steps, about 150 ms on Asha's phone. - Lines 4 to 7 hide the teas that don't match, and count the rest. Line 8 shows how many were found.
- Line 10 runs
search()on everyinputevent. That event fires each time the box's text changes.
Asha types mint, a key every 120 ms. Here is each key press:
// Asha types "mint". In ms, from her first key press.const keys = [ { key: "m", pressed: 0.0, waited: 1.2, took: 320.7 }, { key: "i", pressed: 118.0, waited: 44.2, took: 442.9 }, { key: "n", pressed: 238.3, waited: 78.4, took: 384.4 }, { key: "t", pressed: 358.7, waited: 109.8, took: 264.0 },];for (const key of keys) { console.log(`${key.key}: waited ${key.waited.toFixed(1)} ms, took ${key.took.toFixed(1)} ms`);}- Lines 3 to 6 hold her key presses.
waitedis how long a key waited before its handlers could start.tookis the key press's time as INP counts it, up to the next frame. - It prints
m: waited 1.2 ms, took 320.7 msandi: waited 44.2 ms, took 442.9 ms. Then it printsn: waited 78.4 ms, took 384.4 msandt: waited 109.8 ms, took 264.0 ms.
Each search took about 150 ms, but a new key came every 120 ms. So each key waited for the searches before it, and the waits grew. The slowest key press took 442.9 ms. That one key press would be her INP. Why it matters: a handler that runs on every key press must be quick, or the keys pile up.
The fix is to debounce the search: wait until the typing stops, then search once. The JavaScript lesson Debounce & throttle builds debounce step by step. The system design lesson Debounce and throttle plans whole search boxes with it. Here is a small one, on the second search page:
const debounce = (fn, wait) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), wait); };};const searchSoon = debounce(search, 250);box.addEventListener("input", () => searchSoon(box.value.toLowerCase()));- Lines 1 to 7 are
debounce(). Line 4 cancels the last timer, and line 5 starts a new one. Sofnruns only afterwaitms with no new call. - Line 8 makes
searchSoon, which searches 250 ms after the last call. - Line 9 calls it on every input event.
You order: "A mint tea, and a scone. No, two scones." A waiter who ran to the kitchen after every word would make three trips. A good waiter writes as you speak, and goes once, when you stop.
- In real life: Writing each word as you say it
- In the browser: The box showing each letter at once
- In real life: Running to the kitchen after every word
- In the browser: Searching on every key press
- In real life: Waiting until you stop talking
- In the browser: The 250 ms debounce
- In real life: One trip to the kitchen
- In the browser: One search for the whole word
// Asha types "mint". In ms, from her first key press.const pages = [ { name: "Every key", slowestKey: 442.9, searches: 4, resultsShown: 624.9 }, { name: "Debounced", slowestKey: 10.6, searches: 1, resultsShown: 773.2 },];for (const page of pages) { const runs = page.searches === 1 ? "1 search" : page.searches + " searches"; console.log(`${page.name}: slowest key ${page.slowestKey.toFixed(1)} ms, ${runs}, results at ${page.resultsShown.toFixed(1)} ms`);}- Lines 3 and 4 hold both pages: the slowest key press, how many searches ran, and when the result for mint reached the screen.
- It prints
Every key: slowest key 442.9 ms, 4 searches, results at 624.9 msandDebounced: slowest key 10.6 ms, 1 search, results at 773.2 ms.
Here is what Asha saw on each page:
Searching on every key, her screen showed nothing new for 322.7 ms. Then the box jumped from empty to mi. Debounced, the m showed after 12.9 ms, and every key press took 10.6 ms or less. The search ran once instead of four times.
But look at the last number. The result came 148.3 ms later than before. It waited 250 ms of quiet first. Why it matters: debouncing keeps typing smooth and saves work. Pick a wait a little longer than the gap between key presses.
Scrolls and drags: once per frame
Some events fire many times a second, like scroll and pointermove. There, you want updates while they happen, not only after. web.dev's article Debounce your input handlers gives the rule: keep the handler short, then "store event values and deal with style changes in the next requestAnimationFrame callback." The JavaScript lesson calls this a requestAnimationFrame throttle. It does the work at most once per frame, with the newest value.
Now try a slow search in your own browser. Type at your normal speed, then switch to Debounced:
const search = () => { busyFor(120); filter(box.value); };const searchSoon = debounce(search, 250);box.addEventListener("input", debounced ? searchSoon : search);- Mint tea
- Green tea
- Earl Grey
- Chamomile
- Jasmine
- Masala chai
Type a tea's name, like mint, at your normal speed.
Before the frame, after it, or when quiet?
Every job in an event handler fits one of three times. Here are this lesson's four fixes. Read each row as one fix: what it changes, and what the recording showed.
| The fix | What it changes | What the recording showed |
|---|---|---|
| Show the change, then yield | The frame comes before the rest of the work. | Cart: 1 showed after 46.0 ms, not 252.8 ms. |
| Run quiet work in idle time | Work Asha can see goes first. Saving and analytics wait. | The suggestions showed after 115.9 ms, not 257.3 ms. |
| Mark the listener passive | The page scrolls without waiting for the listener. | The list moved after 3.7 ms, not 118.9 ms. |
| Debounce the search | The search runs once, after the typing stops. | The slowest key took 10.6 ms, not 442.9 ms. |
Now sort these jobs from a real shop. When should each one run?
- Change the badge to Cart: 1
- Turn the Add button into Added
- Highlight the row she clicked
- Show the teas that go well with Mint tea
- Show how much more she needs for free delivery
- Save the cart to localStorage
- Send the add_to_cart event to analytics
- Get the checkout page's data ready
Sort each job by when it should run after Asha's click.
Common mix-ups
- "Yielding makes the work faster." No. After the yield, the three jobs still ran as one task of 208.3 ms. Yielding changed what Asha saw first, not how long the work took.
- "If the click's INP is good, the work is done." No. INP stops at the next frame. On the yield page, the jobs ran on after it, and a second click would have waited.
- "localStorage writes in the background." No. It's synchronous. A 1 MB write blocked Asha's phone for 5.2 ms on average.
- "A passive listener doesn't run." No. It runs as usual. It just can't stop the scroll. The passive listener still ran for 103.3 ms.
- "Every wheel listener holds the scroll back." No. In Chrome and Firefox, wheel listeners on the window, the document and the body are passive by default. The window's listener let the list move after 3.6 ms.
- "Debouncing makes the results come sooner." No. They came 148.3 ms later. Debouncing keeps typing smooth, and runs the search once.
Practice exercises
Answer first, then use the hints.
This program follows the DOM standard's rule for passive. What does it print?
// The DOM standard's rule for passive, when the code doesn't say.
const roots = ["window", "document", "html", "body"];
const types = ["touchstart", "touchmove", "wheel", "mousewheel"];
const passiveByDefault = (type, target) => types.includes(type) && roots.includes(target);
console.log(passiveByDefault("wheel", "body"), passiveByDefault("wheel", "#teas"), passiveByDefault("click", "window"));true false false. A wheel listener on the body is passive by default. On the tea list it isn't, and a click listener never is.
Asha types, pauses, and types again. With a 250 ms debounce, what does the program print?
// Key presses, in ms. A debounced search runs when the gap after a key is at least the wait.
const keys = [0, 120, 240, 360, 900, 1020];
const wait = 250;
let searches = 0;
keys.forEach((at, i) => {
const next = keys[i + 1];
if (next === undefined || next - at >= wait) searches += 1;
});
console.log("Searches:", searches);Searches: 2. One search after the 360 ms key press, and one after the last key. Asha typed two words with a pause between.
In the click playground, pick Then a frame. How many ms after the click did the suggestions reach the screen?
254.9 ms. The badge came first, but the suggestions still waited for saving and analytics, as on the yield page.
In the click playground, pick All at once. How long did saveCart() run, in ms?
42.2 ms. Nearly all of it was the stand-in work that packs the cart. The write itself was much shorter.
Open the course's trace of the all-at-once click in Chrome's DevTools. In the Bottom-up tab, which function has the most self time?
work. All three slow jobs call the stand-in loop, so most of the time is spent inside it. The Call tree tab shows which job called it.
In Chrome's scheduler, which state has the comment about the user waiting for a discrete input event?
kWaitingForInputResponse. It's one value of RenderingPrioritizationState. While Chrome waits to show the result of a click or key press, the next frame gets the highest priority.
Check your understanding
For each question, ask who is waiting for the work: Asha, or nobody yet.
Question 1 of 7On the all-at-once page, Ravi's handler changed the badge first. Why did Asha see the new badge only when all four jobs were done?
Choose an answer to see the explanation.
Question 2 of 7After
await scheduler.yield(), the other three jobs still ran. So why did Chrome time the click as much quicker?Choose an answer to see the explanation.
Question 3 of 7Which of Add to cart's jobs should run before the yield?
Choose an answer to see the explanation.
Question 4 of 7What does
localStorage.setItem()do to the page while it writes?Choose an answer to see the explanation.
Question 5 of 7In Chrome, Ravi writes
window.addEventListener("wheel", noteScroll)with no options. Does scrolling wait for noteScroll?Choose an answer to see the explanation.
Question 6 of 7A passive wheel listener calls
event.preventDefault(). What happens?Choose an answer to see the explanation.
Question 7 of 7Ravi debounces the search box. What changes for Asha as she types?
Choose an answer to see the explanation.
Key takeaways
- A handler's changes show only in the next frame, and the frame waits for the whole task.
- Change what the visitor is waiting for first, then yield, so a frame can show it.
- In Chrome, the frame after a click or key press goes first. Elsewhere, wait for a frame with requestAnimationFrame and setTimeout.
- Run saving and analytics in idle time, with a timeout, and finish them when the page is hidden.
- localStorage is synchronous, so keep writes small. sendBeacon() sends without waiting.
- Mark touch and wheel listeners passive unless they stop the scroll. Safari doesn't make wheel listeners passive by default.
- Debounce slow work that runs on every key press. For scrolls and drags, do the work once per frame.
Remember the one-liner.
Show the change first; save, send and search later.
Sources
The facts on this page come from these specs, docs and source files:
- Optimize Interaction to Next Paint, web.dev.
- Optimize long tasks, web.dev.
- main_thread_scheduler_impl.h, Chromium, Chromium on GitHub.
- Window: requestIdleCallback() method, MDN.
- Cooperative Scheduling of Background Tasks (requestIdleCallback), W3C.
- Web Storage API, MDN.
- Storage for the web, web.dev.
- Navigator: sendBeacon() method, MDN.
- EventTarget: addEventListener() method, MDN.
- DOM Standard: Interface EventTarget, WHATWG.
- Making touch scrolling fast by default, Chrome for Developers.
- Making wheel scrolling fast by default, Chrome for Developers.
- input_handler.cc, Chromium, Chromium on GitHub.
- Debounce your input handlers, web.dev.
Coming next: Reducing presentation delay. After the handlers, the browser still has to draw the frame. The next lesson makes that part smaller.