completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Answer 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.
  • 02
    Move quiet work out of the wayRun saving and analytics in idle time, finish them when the page is hidden, and know that localStorage blocks.
  • 03
    Keep 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.

Real frames of Asha's screen after she clicked Add on Mint tea. All at once: the screen still said Cart: 0 until 252.8 ms after her click, when Cart: 1 and the suggestions appeared together. Screen first: Cart: 1 appeared after 44.9 ms, and the suggestions after 115.9 ms.All at onceher clickThe tea list with Cart: 0Cart: 0252.8 msCart: 1, and the line Goes well with Mint teaCart: 1 + suggestionsno new frameScreen firsther clickThe tea list with Cart: 0Cart: 044.9 msCart: 1, and the line under the search box unchangedCart: 1115.9 msCart: 1, and the line Goes well with Mint teaCart: 1 + suggestions
Real frames from the course's recording on Asha's phone, in ms after her click. All at once, Chrome drew no new frame until all four jobs were done. Screen first, the same jobs ran, but Cart: 1 came first.

Here are the same times as code:

When Asha saw each changePop out in the code editor (opens in a new tab)JavaScript
// 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. cartShown is the first frame with Cart: 1. suggestionsShown is the first frame with the suggestions.
  • Line 7 prints All at once: Cart: 1 after 252.8 ms, the suggestions after 252.8 ms and Screen 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.

Real-life analogyA waiter brings your tea first

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
The main idea

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:

  1. Show the change first, then yield.
  2. Move saving and analytics to quiet moments.
  3. Let scrolling start without waiting for your listener.
  4. 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:

cart.js: Add to cart, all at onceJavaScript
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 with addEventListener. 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:

cart.js: the four jobsJavaScript
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?

Where the click's time wentPop out in the code editor (opens in a new tab)JavaScript
// 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., then The click's handlers ran for 210.1 ms. and Cart: 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:

cart.js: the badge, then a yieldJavaScript
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 with await.
  • 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:

Two clicks, side by sidePop out in the code editor (opens in a new tab)JavaScript
// 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. and Then 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:

The main thread after Asha's click, in each version, to scale from 0 to 270 ms. All at once: suggestions from 5.7 for 65.8 ms, save from 71.5 for 42.2 ms, analytics from 113.7 for 99.3 ms; Cart: 1 on screen at 252.8 ms, the suggestions at 252.8 ms. Then yield: suggestions from 43.5 for 65.4 ms, save from 108.9 for 42.3 ms, analytics from 151.2 for 100.0 ms; Cart: 1 on screen at 46.0 ms, the suggestions at 257.3 ms. Then a frame: suggestions from 44.1 for 61.7 ms, save from 105.8 for 42.6 ms, analytics from 149.1 for 99.3 ms; Cart: 1 on screen at 46.2 ms, the suggestions at 254.9 ms. Screen first: suggestions from 42.9 for 65.4 ms, save from 113.6 for 41.8 ms, analytics from 157.8 for 99.3 ms; Cart: 1 on screen at 44.9 ms, the suggestions at 115.9 ms.All at oncesuggestionssaveanalyticsCart: 1 + suggestionsThen yieldsuggestionssaveanalyticsCart: 1suggestionsThen a framesuggestionssaveanalyticsCart: 1suggestionsScreen firstsuggestionssaveanalyticsCart: 1suggestionsher click100 ms200 ms
The main thread after Asha's click, to scale, from the course's recording. Each bar is one job, and the shaded one is the job she can see. The ticks under a row mark when Cart: 1 and the suggestions reached her screen.

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:

Wait for a frame, in any browserJavaScript
const afterNextFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));const yieldToFrame = () => (globalThis.scheduler?.yield ? scheduler.yield() : afterNextFrame());
  • Line 1 is afterNextFrame from lesson 27.3. requestAnimationFrame runs 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, and afterNextFrame() 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:

Playground: one click, four versions
The three slow jobsAll at once
  1. showSuggestions()started at 5.7 ms, ran 65.8 ms
  2. saveCart()started at 71.5 ms, ran 42.2 ms
  3. sendAnalytics()started at 113.7 ms, ran 99.3 ms
Try it yourself
All four jobs at once: the click took 250.8 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.

From the course's recording in Chrome 157, with the CPU slowed 4 times: the middle of three clicks for each version. Every time is in ms after Asha's click.

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:

cart.js: screen first, quiet work laterJavaScript
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 met requestIdleCallback(). The timeout says: if no idle period comes within 1 second, run the job anyway.
  • Line 6 changes the badge.
  • Lines 7 and 8 ask for saveCart and sendAnalytics in 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.
Real-life analogyThe ledger can wait for a quiet moment

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
When each job ranPop out in the code editor (opens in a new tab)JavaScript
// 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., then saveCart started at 113.0 ms and ran 42.4 ms. and sendAnalytics 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 write to localStorage, three sizesPop out in the code editor (opens in a new tab)JavaScript
// 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 ms and 1 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:

Playground: how long a localStorage write blocks
What each write doesJavaScript
const text = "x".repeat(size);const start = performance.now();localStorage.setItem("cf-storage-lab", text);const took = performance.now() - start;
Your browserNot run yet

Nothing written yet.

Try it yourself
Pick a size

Pick a size. The lab writes a text of that many letters to localStorage five times, and times each write.

Real writes in your browser, timed with performance.now(). Browsers round that clock, so small writes can show as 0 ms. The lab removes what it wrote.

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:

Save the cart when Asha leavesJavaScript
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.

track.js: a wheel listener on the listJavaScript
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".

Real-life analogyA guard who only counts visitors

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:

Two more ways to listenJavaScript
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.
When the list movedPop out in the code editor (opens in a new tab)JavaScript
// 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. moved is when the first frame showed the list moved, counted from when the wheel reached the page. listenerRan is how long noteScroll ran.
  • It prints On the list: the list moved after 118.9 ms. The listener ran 103.1 ms., then Passive: the list moved after 3.7 ms. The listener ran 103.3 ms. and On the window: the list moved after 3.6 ms. The listener ran 104.2 ms.
Real frames of the tea list as Asha turned her mouse wheel. With the listener on the list, the first frame that showed the list moved came 118.9 ms after the wheel reached the page. With the passive listener, it came after 3.7 ms.On the listthe wheelThe tea list at the top of the pagenot moved118.9 msThe tea list scrolled downmoved 200 pxPassivethe wheelThe tea list at the top of the pagenot moved3.7 msThe tea list scrolled downmoved 200 px
Real frames from the course's recording, in ms after the wheel reached the page. The listener on the window behaved like the passive one.

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?

preventDefault() in a passive listenerPop out in the code editor (opens in a new tab)JavaScript
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:

Playground: a slow listener on a scrolling list
The list's listenerJavaScript
box.addEventListener("wheel", () => {  busyFor(60); // a slow listener  wheels += 1;}, { passive });
The tea list0 events
  • 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
Try it yourself
The listener is not passive

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.

A real listener in your browser. Feel the difference: without passive, the list waits for the listener before it moves. Browsers without touch or wheel input fire no events here.

Search when Asha stops typing

The tea list has a search box. Ravi's search.js searches the whole shop on every key press:

search.js: a search on every keyJavaScript
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 every input event. That event fires each time the box's text changes.

Asha types mint, a key every 120 ms. Here is each key press:

Four key presses, a search on eachPop out in the code editor (opens in a new tab)JavaScript
// 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. waited is how long a key waited before its handlers could start. took is the key press's time as INP counts it, up to the next frame.
  • It prints m: waited 1.2 ms, took 320.7 ms and i: waited 44.2 ms, took 442.9 ms. Then it prints n: waited 78.4 ms, took 384.4 ms and t: 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:

search.js: debouncedJavaScript
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. So fn runs only after wait ms with no new call.
  • Line 8 makes searchSoon, which searches 250 ms after the last call.
  • Line 9 calls it on every input event.
Real-life analogyA waiter who lets you finish your order

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
Every key against debouncedPop out in the code editor (opens in a new tab)JavaScript
// 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 ms and Debounced: slowest key 10.6 ms, 1 search, results at 773.2 ms.

Here is what Asha saw on each page:

Real frames of the search box as Asha typed mint. Searching on every key: an empty box at her first key press, then mi and 3 teas at 322.7 ms, then mint and 1 tea at 624.9 ms. Debounced: m at 12.9 ms, mint with every tea still listed at 365.9 ms, then 1 tea at 773.2 ms.Every keyfirst keyThe search box, emptyempty box322.7 msThe box says mi, and 3 teas are foundmi, 3 teas624.9 msThe box says mint, and only Mint tea is listedmint, 1 teaDebounced12.9 msThe box says m, and every tea is listedm365.9 msThe box says mint, and every tea is still listedmint, all teas773.2 msThe box says mint, and only Mint tea is listedmint, 1 tea
Real frames from the course's recording, in ms after Asha's first key press. She pressed the last key at 358.7 ms. Searching on every key, the box skipped letters. Debounced, each letter showed at once, and the result came later.

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:

Playground: search as you type
What each key press doesJavaScript
const search = () => { busyFor(120); filter(box.value); };const searchSoon = debounce(search, 250);box.addEventListener("input", debounced ? searchSoon : search);
Search teas40 found
Try it yourself
Every key: 0 searches so far

Type a tea's name, like mint, at your normal speed.

A real search in your browser. Each search keeps the page busy for 120 ms. Key press times come from your browser's Event Timing API and change from run to run.

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.

Four fixes for slow event handlers
The fixWhat it changesWhat the recording showed
Show the change, then yieldThe frame comes before the rest of the work.Cart: 1 showed after 46.0 ms, not 252.8 ms.
Run quiet work in idle timeWork Asha can see goes first. Saving and analytics wait.The suggestions showed after 115.9 ms, not 257.3 ms.
Mark the listener passiveThe page scrolls without waiting for the listener.The list moved after 3.7 ms, not 118.9 ms.
Debounce the searchThe 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?

Before the frame, after it, or when quiet?
  • 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
Try it yourself
0 of 8 correct

Sort each job by when it should run after Asha's click.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Exercise 1 · Warm-upPassive when the code doesn't say

This program follows the DOM standard's rule for passive. What does it print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// 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"));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upHow many searches?

    Asha types, pauses, and types again. With a 250 ms debounce, what does the program print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // 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);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeSuggestions without scheduler.yield

      In the click playground, pick Then a frame. How many ms after the click did the suggestions reach the screen?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe cost of saving

        In the click playground, pick All at once. How long did saveCart() run, in ms?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeBottom-up in DevTools

          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?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe frame after a click

            In Chrome's scheduler, which state has the comment about the user waiting for a discrete input event?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask who is waiting for the work: Asha, or nobody yet.

              The fast event handlers quiz · 7 questionsScore: first tries count
              1. 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.

              2. 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.

              3. Question 3 of 7Which of Add to cart's jobs should run before the yield?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does localStorage.setItem() do to the page while it writes?

                Choose an answer to see the explanation.

              5. 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.

              6. Question 6 of 7A passive wheel listener calls event.preventDefault(). What happens?

                Choose an answer to see the explanation.

              7. 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:

              Coming next: Reducing presentation delay. After the handlers, the browser still has to draw the frame. The next lesson makes that part smaller.

              CompleteFrontend Clear concepts. Working examples.