completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Breaking up long work

Cut long work by a time budget, do what the visitor sees first, prepare values in idle time but at once when needed, and skip work you don't need.

By the end, you can
  • 01
    Cut work by a time budgetYield when a budget of milliseconds runs out, and explain why a 50 ms budget still makes tasks just over 50 ms.
  • 02
    Put visible work firstDo the work for what's on screen first, then let a frame through, so the visitor sees it.
  • 03
    Prepare work early, or skip itBuild a value in idle time but at once when a click needs it, and move work you don't need off the phone.

Why do Asha's ratings come last?

Meera has a new wish for Leaf and Kettle. Every tea on the list should show its star rating. The list has grown to 40 teas. Ravi's reviews.js already reads this week's 4,000 reviews. Now it also works out each tea's rating from its 100 reviews.

Ravi keeps what lesson 27.2 taught him. reviews.js runs on the main thread: the one thread that runs the page's JavaScript and answers clicks. After every 100 reviews, reviews.js yields. It stops for a moment, so the browser can run something else, like a click. It yields with scheduler.yield(). So a click never waits long.

Asha opens the tea list on her phone. Her screen shows six teas. Next to each one, three dots wait for a rating. The line above them counts the reviews: 100, then 2,200, then more. But her six dots stay dots. The ratings fill in further down the list, out of sight. Hers come last.

Ravi tries Ben's older phone too. There, each piece of work takes longer.

So Ravi has two questions. How much work should go in each piece? And which piece should go first?

Here is what the course measured on Asha's phone. One version is Ravi's page. The other is the reviews.js this lesson builds.

When Asha's six ratings appearedPop out in the code editor (opens in a new tab)JavaScript
// Asha's phone: when her six ratings appeared, and when all 40 had. In ms after reviews.js started.const versions = [  { name: "Every 100 reviews", screen: 680.6, all: 680.6 },  { name: "Screen first, 10 ms budget", screen: 103.8, all: 695.5 },];for (const version of versions) {  console.log(`${version.name}: her six at ${version.screen} ms, all 40 at ${version.all} ms`);}
  • Line 1 is a comment. Times are in milliseconds (ms) after reviews.js started. A millisecond is a thousandth of a second.
  • Lines 3 and 4 hold the two versions. screen is when Asha's six ratings appeared. all is when all 40 had.
  • Line 7 prints Every 100 reviews: her six at 680.6 ms, all 40 at 680.6 ms. Then it prints Screen first, 10 ms budget: her six at 103.8 ms, all 40 at 695.5 ms.

Here is what Asha saw. These are real frames Chrome captured. A frame is one picture the browser draws on the screen.

Real frames of Asha's screen, in ms after reviews.js started. Every 100 reviews, in the file's order: at 120 ms: 0 of 6 ratings, 100 reviews read; at 450 ms: 0 of 6 ratings, 2,200 reviews read; done, 680.6 ms: 6 of 6 ratings, 4,000 reviews read. A 10 ms budget with the screen's teas first: at 120 ms: 6 of 6 ratings, 600 reviews read; at 450 ms: 6 of 6 ratings, 2,400 reviews read; done, 695.5 ms: 6 of 6 ratings, 4,000 reviews read.Every 100 reviews,the file's orderat 120 msThe tea list with no ratings on screen and Reviews read: 100 of 40000 of 6 rated100 reviews readat 450 msThe tea list with no ratings on screen and Reviews read: 2200 of 40000 of 6 rated2,200 reviews readdone, 680.6 msThe tea list with 6 of 6 ratings on screen and Reviews read: 4000 of 40006 of 6 rated4,000 reviews read10 ms budget,screen firstat 120 msThe tea list with 6 of 6 ratings on screen and Reviews read: 600 of 40006 of 6 rated600 reviews readat 450 msThe tea list with 6 of 6 ratings on screen and Reviews read: 2400 of 40006 of 6 rated2,400 reviews readdone, 695.5 msThe tea list with 6 of 6 ratings on screen and Reviews read: 4000 of 40006 of 6 rated4,000 reviews read
Real frames from the course's recording on Asha's phone. Each one is what her screen showed at that moment. In the file's order, her six ratings came with the last frame. With the screen first, they came in the first frame Chrome drew.

The work took about as long both ways. But on the new page, Asha saw her six ratings 576.8 ms sooner. Why it matters: a visitor doesn't watch your work finish. She sees the frames along the way.

Real-life analogyUnpacking a delivery while the shop is open

A delivery of 40 boxes arrives while the shop is open. Ravi unpacks them between customers. Two choices shape his morning. How long does he unpack before he looks up? And which boxes does he open first? The window shelf is what people see from the street.

In real life: The 40 boxes
In the browser: reviews.js's work: 40 teas to rate
In real life: Looking up to serve a customer
In the browser: Yielding, so a click can get in
In real life: How long he unpacks before he looks up
In the browser: How much work goes in each piece
In real life: The boxes for the window shelf
In the browser: The ratings on Asha's screen
The main idea

Cut long work by time, not by count, so each piece is short on every phone. Do the part the visitor can see first. Prepare work in quiet moments. And skip work the phone doesn't need to do. Yielding lets clicks in. These choices decide what the visitor sees, and when.

The JavaScript lesson Keeping the main thread responsive built a helper with a time budget. The system design lesson Runtime performance plans a whole app around these ideas. This lesson measures them on Ravi's page, in four steps:

  1. Cut the work by time, not by count.
  2. Do the work Asha can see first.
  3. Prepare a value in idle time, but at once if a click needs it.
  4. Skip the work the phone doesn't need to do.

How the course recorded it

Every version of reviews.js starts with the same two helpers:

reviews.js: reading a review, showing a ratingJavaScript
// reviews.js: works out each tea's star rating from this week's 4,000 reviews.const about = document.querySelector("#about");const yieldNow = () => scheduler.yield();let read = 0;function readReview(stars) {  work(23_000); // reading one review's words  read += 1;  return stars;}function showRating(tea, total) {  const row = document.querySelector(`[data-tea="${tea.id}"]`);  row.querySelector(".stars").textContent = "★ " + (total / tea.stars.length).toFixed(1);  about.textContent = "Reviews read: " + read + " of 4000";}
  • Line 3 makes yieldNow, the way to yield from lesson 27.2.
  • Lines 5 to 9 read one review. Line 6 does 23,000 steps of work with work(), from lesson 26.3. It stands in for reading the review's words. Line 8 gives back the review's stars, from 1 to 5.
  • Lines 10 to 14 show a tea's rating. Line 12 writes the average of its stars, like ★ 4.6. Line 13 updates the line that counts the reviews.
  • Eight pages: the same tea list, with reviews.js cut up in different ways. One page has no reviews.js at all.
  • Two phones: this computer in Chrome 157, with its processor, the CPU, slowed down. Asha's phone is 4 times slower, as in lessons 27.1 and 27.2. Ben's older phone is 6 times slower.
  • Loads: each page loaded three times on Asha's phone, taking turns. Five pages also loaded three times on Ben's. The server held this week's reviews back 1 second, like a slow network, so the list appeared first. Each time, Chrome kept a trace: a detailed record of everything it did, with screenshots. The lesson quotes the middle load of each three.
  • Clicks: on one page, Asha clicks Add on Mint tea, at two moments, three loads each. The click is a mouse press and release, sent through Chrome's DevTools protocol, the way programs drive Chrome. Chrome passes it straight to the page, as it does a real mouse.

Cut the work by time, not by count

Here is the loop on Ravi's page. It yields after each tea, which is every 100 reviews:

rateAll, a piece per teaJavaScript
async function rateAll(teas) {  for (const tea of teas) {    let total = 0;    for (const stars of tea.stars) total += readReview(stars);    showRating(tea, total);    await yieldNow(); // after each tea: every 100 reviews  }}rateAll(TEAS);
  • Line 2 goes through the teas in the file's order.
  • Line 4 reads the tea's 100 reviews and adds up their stars.
  • Line 5 shows the rating. Line 6 yields.
  • Line 9 starts it with TEAS: this week's reviews, as the server sent them.

So each piece is one tea: 100 reviews. Each piece runs as its own task: one job the main thread runs from start to end, without stopping. How long is a piece? That depends on the phone:

100 reviews a piece, on two phonesPop out in the code editor (opens in a new tab)JavaScript
// Every 100 reviews: the pieces on two phones, from the recording. In ms.const phones = [  { name: "Asha's phone", pieces: 40, typical: 16.7 },  { name: "Ben's phone", pieces: 40, typical: 25.9 },];for (const phone of phones) {  console.log(`${phone.name}: ${phone.pieces} pieces of about ${phone.typical} ms`);}
  • Lines 3 and 4 hold each phone's pieces.
  • Line 7 prints Asha's phone: 40 pieces of about 16.7 ms and Ben's phone: 40 pieces of about 25.9 ms.

"About" means the typical piece: half were shorter, half longer. The count stayed at 40. The length followed the phone. Ben's pieces ran about half as long again as Asha's. A slower phone, or a week of longer reviews, would stretch them more. Why it matters: a count you pick on your own computer can't tell you how long a piece takes on a visitor's phone.

Real-life analogyChecking on the baby by the clock

Ravi babysits his niece while he reads. He promised to look in on her often. Every 10 pages works with a light novel. With a hard book, 10 pages take much longer, and she waits. So he looks in every 10 minutes instead. Then any book works.

In real life: The pages of the book
In the browser: The reviews reviews.js reads
In real life: How fast Ravi reads
In the browser: How fast the phone's CPU is
In real life: Every 10 pages
In the browser: Every 100 reviews: a count
In real life: Every 10 minutes
In the browser: A time budget, like 10 ms
In real life: His niece, waiting for him
In the browser: A click, waiting for the main thread

That's a time budget: how long one piece may run before it yields. The JavaScript lesson's processInChunks had one. web.dev shows the same idea. Here it is in reviews.js:

rateAll, with a time budgetJavaScript
const BUDGET_MS = 50;async function rateAll(teas) {  let start = performance.now();  for (const tea of teas) {    let total = 0;    for (const stars of tea.stars) {      total += readReview(stars);      if (performance.now() - start > BUDGET_MS) {        await yieldNow();        start = performance.now();      }    }    showRating(tea, total);  }}rateAll(TEAS);
  • Line 1 sets the budget: 50 ms.
  • Line 3 notes when the piece started. performance.now() gives the time in ms.
  • Lines 6 to 12 read the tea's reviews one at a time. After each one, line 8 checks how long the piece has run.
  • Once the budget is used up, line 9 yields. Line 10 starts the clock again for the next piece.
  • Line 13 shows the rating. A tea's reviews can now end mid-piece, or span two pieces.

Another page sets line 1 to 10 ms. Here are both budgets on both phones:

Two time budgets, on two phonesPop out in the code editor (opens in a new tab)JavaScript
// A time budget: the pieces on two phones, from the recording. In ms.const pages = [  { budget: 50, phone: "Asha's phone", pieces: 14, typical: 50.5 },  { budget: 50, phone: "Ben's phone", pieces: 20, typical: 50.8 },  { budget: 10, phone: "Asha's phone", pieces: 67, typical: 10.5 },  { budget: 10, phone: "Ben's phone", pieces: 96, typical: 10.6 },];for (const page of pages) {  console.log(`${page.budget} ms, ${page.phone}: ${page.pieces} pieces of about ${page.typical} ms`);}
  • Lines 3 to 6 hold four loads.
  • Line 9 prints 50 ms, Asha's phone: 14 pieces of about 50.5 ms and 50 ms, Ben's phone: 20 pieces of about 50.8 ms. Then it prints 10 ms, Asha's phone: 67 pieces of about 10.5 ms and 10 ms, Ben's phone: 96 pieces of about 10.6 ms.

Now it's the other way round. The budget fixed the length. The phone decided how many pieces there were. Why it matters: you choose how long a click may wait, and that holds on every phone.

The first 200 ms after reviews.js started, to scale, on Asha's phone and Ben's. Every 100 reviews, Asha's phone: 13 pieces in view, 40 in all. Every 100 reviews, Ben's phone: 8 pieces in view, 40 in all. 50 ms budget, Asha's phone: 4 pieces in view, 14 in all. 50 ms budget, Ben's phone: 4 pieces in view, 20 in all. 10 ms budget, Asha's phone: 18 pieces in view, 67 in all. 10 ms budget, Ben's phone: 17 pieces in view, 96 in all.Every 100Asha40 piecesreviewsBen40 pieces50 msAsha14 piecesbudgetBen20 pieces10 msAsha67 piecesbudgetBen96 piecesreviews.js starts100 ms200 ms
The pieces of reviews.js's work in its first 200 ms, from the course's recording. Cut every 100 reviews, the pieces stretch on Ben's slower phone. Cut by a time budget, they stay the same length, and his phone needs more of them.

A 50 ms budget makes tasks just over 50 ms

Look at the 50 ms pieces again: about 50.5 ms, not 50. The check runs after a review, so a piece always ends a little past its budget. The quiz has a small example. The first piece ran longest, 56.9 ms on Asha's phone. In the same task, Chrome also finished loading the page.

Lesson 27.1 called a task of 50 ms or more a long task. So most of these pieces are long tasks. They add a little to Total Blocking Time, or TBT: the part of each long task past 50 ms, added up. TBT was 15.5 ms on Asha's phone and 27.2 ms on Ben's. With a 10 ms budget, it was 0 on both.

web.dev calls 50 ms "a common deadline", to try to keep tasks from becoming long tasks. It adds that the deadline "can be adjusted". A smaller budget answers clicks sooner. A bigger one finishes the whole job a little sooner. Why it matters: pick a budget well under 50 ms if every task must stay under the line.

What the yields cost

More pieces mean more yields. Did they slow the work down? On these runs, every cut version finished at most 89.4 ms after the one task did. web.dev says scheduler.yield() "is quick and efficient, but it does have some overhead." But during the one task, a click would have waited for the whole rest of it. Pick a way below to see each phone's numbers:

Playground: one job, cut four ways, on two phones
Both phonesevery 100 reviews
What was measuredAsha's phoneBen's phone
Tasks for the ratings4040
A typical task16.7 ms25.9 ms
The longest task20.5 ms34.9 ms
Total Blocking Time0 ms0 ms
All reviews read after673.9 ms1,061.9 ms
Try it yourself
A piece per tea: every 100 reviews: 40 tasks on Asha's phone, 40 on Ben's

The longest task ran 20.5 ms on Asha's phone and 34.9 ms on Ben's. All 4,000 reviews were read after 673.9 ms and 1,061.9 ms.

From the course's recording in Chrome 157: the middle of three loads on each phone. Asha's phone is this computer with its CPU slowed 4 times; Ben's, 6 times.

Why it matters: a small budget costs a little time at the end, and buys short waits all the way through.

Why not yield only when someone clicks?

Chrome has a way to ask whether input is waiting: navigator.scheduling.isInputPending(). The idea was to keep working until someone clicks, and only then yield. Chrome has had it since version 87, and the recorded Chrome still had it. Firefox and Safari never added it.

Chrome's own guide on web.dev now says not to use it. It gives three reasons:

  • It "may incorrectly return false despite a user having interacted".
  • Input isn't the only thing waiting. Animations and other updates to the screen need a turn too.
  • scheduler.postTask() and scheduler.yield() now do the job better.

MDN says it has been "superseded" by scheduler.yield(). Why it matters: yield on a budget, whether or not anyone is clicking.

Do the work Asha can see first

A time budget fixes how long each piece runs. It doesn't fix the order. reviews.js still reads the teas in the file's order. The file comes from the shop's database, and it lists the top of the list last. So Asha's six ratings come in the last few pieces.

Her screen shows six teas, from Mint tea down to Masala chai. A tea is on screen if its row starts above the bottom of the window. That's easy to check:

The screen's teas firstJavaScript
const onScreen = (tea) => document.querySelector(`[data-tea="${tea.id}"]`).getBoundingClientRect().top < innerHeight;const first = TEAS.filter(onScreen);const rest = TEAS.filter((tea) => !onScreen(tea));rateAll([...first, ...rest]);
  • Line 1 finds a tea's row. getBoundingClientRect().top is how far the row's top is from the top of the window, in pixels. innerHeight is the window's height.
  • Lines 2 and 3 split the teas in two. filter keeps the file's order inside each group.
  • Line 4 reads the screen's teas first, then the rest. It uses the 10 ms budget.
Real-life analogyTidying the hall before the guests arrive

Guests are coming at seven. Meera can't clean the whole flat in time. So she tidies the hall and the living room first, where the guests will look. The cupboards can wait. When the bell rings, she opens the door to a tidy room.

In real life: The guests
In the browser: Asha, looking at her screen
In real life: The hall and the living room
In the browser: The six teas on her screen
In real life: The cupboards
In the browser: The 34 teas below them
In real life: Opening the door
In the browser: Chrome drawing a frame

Here are three orders on Asha's phone, each with a 10 ms budget:

Three orders, the same workPop out in the code editor (opens in a new tab)JavaScript
// Asha's phone, a 10 ms budget each. When her six ratings were ready, and when they appeared. In ms after reviews.js started.const orders = [  { name: "The file's order", ready: 725.9, seen: 732.7 },  { name: "The screen's teas first", ready: 106.2, seen: 139.2 },  { name: "The screen's teas first, then a frame", ready: 91.3, seen: 103.8 },];for (const order of orders) {  console.log(`${order.name}: ready at ${order.ready} ms, on screen at ${order.seen} ms`);}
  • Lines 3 to 5 hold three loads. ready is when the last of her six ratings was written. seen is the first frame that showed all six.
  • Line 8 prints The file's order: ready at 725.9 ms, on screen at 732.7 ms. Next is The screen's teas first: ready at 106.2 ms, on screen at 139.2 ms. Last is The screen's teas first, then a frame: ready at 91.3 ms, on screen at 103.8 ms.

With the screen's teas first, her ratings were ready about seven times sooner. But look at the gap. They were ready at 106.2 ms, and appeared 33 ms later. Why? A rating shows only when Chrome draws a frame. Lesson 27.2 found that scheduler.yield() pieces go ahead of ordinary frames. So Chrome drew the next frame only once the screen had waited a while.

Let one frame through

So the last page lets a frame through, right after the screen's teas:

A frame between the two partsJavaScript
const afterNextFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));rateAll(first).then(afterNextFrame).then(() => rateAll(rest));
  • Line 1 makes afterNextFrame. It returns a promise: an object for a result that comes later. Inside, requestAnimationFrame runs its callback just before the browser draws its next frame. The timer set inside that starts a new task after the frame. Then the promise settles.
  • Line 2 reads the screen's teas, waits for a frame, then reads the rest.

web.dev shows the same trick for event handlers. It calls it "admittedly a bit esoteric", but "an effective method that works in all browsers to prevent non-critical code from blocking the next frame."

With it, Asha's ratings appeared 12.5 ms after they were ready, at 103.8 ms. All the work was done at 691.3 ms. In the file's order it was 725.9 ms: the same work, in a different order. Why it matters: do the visible part first, then make sure a frame shows it.

Three orders on Asha's phone, a 10 ms budget each, in ms after reviews.js started. The file's order: her six ratings on screen at 732.7 ms, all work done at 725.9 ms. The screen's teas first: her six ratings on screen at 139.2 ms, all work done at 703.3 ms. The screen's teas first, then a frame: her six ratings on screen at 103.8 ms, all work done at 691.3 ms.File ordersix on screen, 732.7 msScreen firstsix on screen, 139.2 msScreen first,then a framesix on screen, 103.8 msreviews.js starts250 ms500 ms750 ms
The bars are the reviews being read, from the course's recording. The shaded mark is the first frame that showed Asha's six ratings. All three took about as long. Only the order changed what she saw first.

The scheduling spec makes the same point. It names "prioritizing work associated with content within the viewport" as a reason to schedule. The viewport is the part of the page inside the window. Pick an order below to see every frame Asha saw:

Playground: every frame Asha saw
Frames, in orderfile order
  1. 131.1 ms0 of 6 ratings on screen, 700 reviews read
  2. 239.2 ms0 of 6 ratings on screen, 1,300 reviews read
  3. 346.3 ms0 of 6 ratings on screen, 1,900 reviews read
  4. 454 ms0 of 6 ratings on screen, 2,500 reviews read
  5. 561.2 ms0 of 6 ratings on screen, 3,100 reviews read
  6. 669.5 ms2 of 6 ratings on screen, 3,600 reviews read
  7. 732.7 ms6 of 6 ratings on screen, 4,000 reviews read
Try it yourself
The file's order: 7 frames while the reviews were read

Each line is one frame Chrome drew. It says how many of the six ratings on Asha's screen it showed, and how many reviews had been read.

From the course's recording in Chrome 157, on Asha's phone, with a 10 ms budget: the middle of three loads. Times are in ms after reviews.js started.

Idle until urgent: ready early, or at once

Meera's next wish: the cart should show a total, like Cart: 1 · £4.50. To work it out, cart.js needs the shop's price list, with this week's offers and delivery prices. Building it takes 8 million steps. When should cart.js build it?

Philip Walton, an engineer at Google, names the two usual answers in his article "Idle Until Urgent":

  • Eager: build it right away, as the page starts. Every visitor pays for it, even those who never click Add.
  • Lazy: build it when a click first needs it. Then that click pays. In his words, "you're guaranteeing that your expensive code will block user input."

He calls them "probably your two worst options". His answer is in the title. Idle until urgent means the code is "deferred to idle periods but then run immediately as soon as it's needed." An idle period is a stretch when the main thread has nothing else to do.

Real-life analogyPeeling potatoes between orders

The café's cook peels potatoes when the kitchen is quiet. Then they're ready when someone orders chips. If an order comes in first, she doesn't wait for a quiet moment. She peels them right then, and that order takes a little longer.

In real life: A quiet kitchen
In the browser: An idle period
In real life: Peeling the potatoes
In the browser: Building the price list
In real life: An order for chips
In the browser: Asha's click on Add
In real life: Peeling right then, for that order
In the browser: Building it inside the click

Here is the helper, at the top of cart.js:

cart.js: idle until urgentJavaScript
// cart.js: shows the cart's total. The price list it needs takes a while to build.const whenIdle = window.requestIdleCallback ?? ((callback) => setTimeout(callback, 1));const cancelIdle = window.cancelIdleCallback ?? clearTimeout;function idleUntilUrgent(make) {  let value;  const handle = whenIdle(() => { value = make(); });  return () => {    if (value === undefined) {      cancelIdle(handle);      value = make();    }    return value;  };}
  • Lines 2 and 3 pick requestIdleCallback, from lesson 27.2, and its cancel function. Safari doesn't have them, so there the page falls back to a timer.
  • Line 6 asks to run make in idle time, and to keep its result.
  • Line 7 returns a getter: a function that hands back the value.
  • If the value isn't ready yet, line 9 cancels the idle callback, and line 10 makes the value at once.

That's how IdleValue works, in the idlize library from Google Chrome Labs. Its docs say that if the work is still waiting for idle time, "that callback is cancelled, the initialization function is run synchronously, and the result is returned." Synchronously means right then, before anything else runs. The idlize repository is archived, so it gets no more changes. The pattern itself takes a dozen lines.

cart.js: the price list and the clickJavaScript
function buildPriceList() {  work(8_000_000); // stands in for this week's offers and delivery prices  const rows = document.querySelectorAll("#teas li");  return Object.fromEntries([...rows].map((row) => [row.dataset.tea, Number(row.dataset.price)]));}const priceList = idleUntilUrgent(buildPriceList);let total = 0;document.querySelector("#teas").addEventListener("click", function addToTotal(event) {  if (!event.target.matches(".add")) return;  total += priceList()[event.target.closest("li").dataset.tea];  document.querySelector("#total").textContent = "£" + total.toFixed(2);});
  • Lines 1 to 5 build the price list. Line 2 does 8 million steps of stand-in work.
  • Line 6 wraps it, so the build waits for idle time.
  • Line 10 asks for the price list when Asha clicks Add, and adds Mint tea's price to the total.

cart.js runs just before reviews.js. So the page has no idle time until the reviews are read. A click handler is a function that runs when a click arrives. The page's other one, from lesson 26.3, adds to the cart with 6 million steps of its own. The course clicked Add in two loads. One click came 150 ms after the reviews arrived, while they were read. The other came 1 second after, once the page was quiet:

Asha's two clicksPop out in the code editor (opens in a new tab)JavaScript
// Asha clicks Add on Mint tea, on the cart.js page. From the recording, in ms.const clicks = [  { when: "While the reviews were read", waited: 11.8, ran: 61.2, total: 34.4 },  { when: "Once the page was quiet", waited: 1.5, ran: 27.8, total: 0.4 },];for (const click of clicks) {  console.log(`${click.when}: waited ${click.waited}, ran ${click.ran}, ${click.total} of it for the total`);}
  • Lines 3 and 4 hold the two clicks. waited is how long the click waited for the main thread. ran is how long its two handlers ran. total is the part spent in addToTotal.
  • Line 7 prints While the reviews were read: waited 11.8, ran 61.2, 34.4 of it for the total. Then it prints Once the page was quiet: waited 1.5, ran 27.8, 0.4 of it for the total.

The early click waited only 11.8 ms. That's the 10 ms budget at work. Then it built the price list itself. Its task ran 65.9 ms: a long task. The later click found the price list ready. It had been built in idle time, 180.5 ms before the click. So the total took 0.4 ms.

Asha's click on Add, in ms after she clicked. While the reviews were read: it waited 11.8 ms, Add ran 25.1 ms, the total ran 34.4 ms; the price list was built inside the total. Once the page was quiet: it waited 1.5 ms, Add ran 26.3 ms, the total ran 0.4 ms; the price list had been built 180.5 ms earlier, in idle time.While the reviewswere readwaited 11.8 msAdd 25.1 msTotal 34.4 msThe total built the price list itself, inside the click.Once the pagewas quietwaited 1.5 msAdd 26.3 mstotal 0.4 msThe price list was built 180.5 ms before, in idle time.Asha clicks40 ms80 ms
Asha's two clicks, from the course's recording. Clicked early, the total built the price list itself. Clicked later, the price list was already there, built in idle time.

Chrome's Event Timing API, which times each interaction, gave the first click 80 ms and the second 32 ms. Philip Walton puts it this way: "In the worst case, it has the exact same performance characteristics as lazy evaluation". In the best case, the click pays nothing. Why it matters: work a click might need can be ready before the click, without slowing the page down.

One more case is work that must happen even if the visitor leaves, like analytics. An idle callback might never run then. Philip Walton's IdleQueue runs its waiting work when the page is hidden, with { ensureTasksRun: true }. MDN also suggests a timeout for work that must happen. Without one, "it's possible multiple seconds will elapse before the callback is fired."

Try it on this page. Start a stand-in load, then click Add early, or wait until it's done:

Playground: click before the price list is ready, or after
What Start doesJavaScript
// What Start does. busy(ms) keeps the main thread working until the time is up.const busy = (ms) => { const end = performance.now() + ms; while (performance.now() < end) {} };const priceList = idleUntilUrgent(() => { busy(40); return { "mint-tea": 4.5 }; }); // the helper from cart.jsfor (let piece = 1; piece <= 60; piece++) {  busy(10);  await yieldNow();}// Add calls priceList(), then does 5 ms of its own work.
What happenedNot started
    Try it yourself
    What your browser did

    Press Start. Then click Add while the stand-in load runs, or after it's done. Compare how long your click's handler runs.

    Real work on this page's main thread, timed by your browser. Where the browser has no requestIdleCallback, the lab uses a timer instead, as cart.js does. Numbers change from run to run.

    The fastest work is work you skip

    Every version so far read all 4,000 reviews on Asha's phone. And on Ben's. And on every visitor's phone, on every visit. But the ratings are the same for everyone. They change only when new reviews come in.

    So the last page does the work once, on the shop's server. The server writes each rating into the HTML it sends. Here are that page's reviews line and Mint tea's row:

    The server page: ratings already in the HTMLHTML
    <p id="about">Ratings from this week's 4000 reviews.</p>
    <li data-tea="mint-tea" data-name="Mint tea" data-price="4.5"><span class="name">Mint tea</span> <span class="stars">★ 4.6</span> <span class="price">£4.50</span> <button class="add" type="button">Add</button></li>
    • Line 1 is the reviews line. Nothing on this page counts reviews.
    • Line 2 is Mint tea's row. Its rating, ★ 4.6, arrives with the page. The page has no reviews.js at all.
    Real-life analogyMixing the blend once a week

    Leaf and Kettle sells a house blend of mint and green tea. Meera could weigh and mix it for each cup. Instead, she mixes a big tin every Monday. Each cup is then just a scoop.

    In real life: Mixing the blend for every cup
    In the browser: Every phone working out the ratings
    In real life: Mixing a big tin on Monday
    In the browser: The server working them out once
    In real life: A scoop from the tin
    In the browser: The rating arriving in the HTML

    On Asha's phone, the first frame already showed every rating, 119.4 ms after the page started. No task after the first paint ran longer than 1.1 ms. On Ben's phone, the first frame came at 158.1 ms. The best reviews.js page showed Asha's six ratings at 1,123 ms: a second waiting for the reviews, then the work. Why it matters: work that's the same for every visitor belongs on the server, not on every phone.

    Read this table across. Each row is one version on Asha's phone. Times are in ms after the page started, so they include the second the reviews took to arrive.

    Seven versions on Asha's phone
    VersionTasks for the ratingsLongest taskTBTScreen's ratings appearedAll ratings appeared
    One task1.651.8 ms.601.8 ms.1,678.2 ms.1,678.2 ms.
    A piece per tea: every 100 reviews40.20.5 ms.0 ms.1,698.3 ms.1,698.3 ms.
    A 50 ms budget14.56.9 ms.15.5 ms.1,726.7 ms.1,726.7 ms.
    A 10 ms budget, the file's order67.15.6 ms.0 ms.1,755.3 ms.1,755.3 ms.
    The screen's teas first65.17.3 ms.0 ms.1,162.7 ms.1,732.8 ms.
    The screen's teas first, then a frame64.16.3 ms.0 ms.1,123 ms.1,714.7 ms.
    Ratings worked out on the server0.1.1 ms.0 ms.119.4 ms.119.4 ms.

    Remember a result

    Sometimes the same input comes back, and so does the same answer. Then work it out once, and keep it. That's memoization, from the JavaScript lesson Memoization & laziness:

    A rating, worked out oncePop out in the code editor (opens in a new tab)JavaScript
    // Work out a tea's rating once, then reuse it.const ratings = new Map();let worked = 0;function ratingOf(tea, stars) {  if (!ratings.has(tea)) {    worked += 1;    ratings.set(tea, (stars.reduce((sum, star) => sum + star, 0) / stars.length).toFixed(1));  }  return ratings.get(tea);}console.log(ratingOf("mint-tea", [5, 4, 5]), ratingOf("mint-tea", [5, 4, 5]));console.log(`Worked out ${worked} time`);
    • Line 2 makes a Map to keep each tea's rating.
    • Line 5 checks for a kept rating. Only if there's none do lines 6 and 7 work one out.
    • Line 11 asks twice. It prints 4.7 4.7. Line 12 prints Worked out 1 time.

    Skip what nobody sees

    The browser can skip work too. The CSS property content-visibility: auto lets it skip the rendering of parts of the page that are off screen. MDN says it lets the browser "skip an element's rendering work (including layout and painting) until it is needed". A later lesson, DOM size, in Stability and smoothness, covers it.

    Your own JavaScript can wait as well. An IntersectionObserver tells you when an element comes near the screen. reviews.js could rate the 34 lower teas only as Asha scrolls toward them. If she never scrolls, that work never runs. Why it matters: the cheapest piece of work is the one that never has to be cut up.

    Which fix fits?

    Each job below comes from a real shop page. Sort it by the fix that suits it best:

    Which fix fits?
    • Filtering 50,000 rows the visitor just searched
    • Marking spelling mistakes in a 20-page document
    • Formatting a 2,000-row table, of which 15 rows fit on screen
    • Drawing the first photos of a long gallery
    • A date formatter that only the checkout button uses
    • Reading the saved basket from localStorage, for when the cart opens
    • Working out this week's best-sellers, the same for every visitor
    • Building cards for products far down the page, which most visitors never reach
    Try it yourself
    0 of 8 correct

    Sort each job by the fix that suits it best.

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

    Common mix-ups

    • "Yielding every 100 items is as good as a time budget." No. A count fixes how many pieces there are. The phone decides how long each one runs: 16.7 ms on Asha's phone, 25.9 ms on Ben's.
    • "A 50 ms budget keeps every task under 50 ms." No. The check comes after a piece of work, so the task runs a little past it. Most 50 ms pieces were long tasks.
    • "Many small pieces make the work finish much later." No. Every cut version finished within 89.4 ms of the one task here. A yield costs a little, not a lot.
    • "Once the visible work is done, the visitor sees it." No. She sees it in the next frame. With the screen's teas first, the ratings were ready at 106.2 ms and appeared at 139.2 ms.
    • "An idle callback is fine for work a click needs." No. Idle time may not come before the click. Idle until urgent builds the value at once when the click needs it.
    • "isInputPending() lets you skip yielding safely." No. web.dev says it can miss input, and frames need a turn too. Yield on a budget instead.

    Practice exercises

    Answer first, then use the hints.

    Exercise 1 · Warm-upHow many pieces?

    This program models a 10 ms budget on Ben's phone. What does it print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Ben's phone reads one review in 260 microseconds (µs). The budget is 10 ms: 10,000 µs.
    const reviewUs = 260;
    const budgetUs = 10_000;
    let pieces = 1;
    let elapsed = 0;
    for (let review = 1; review <= 4000; review++) {
      elapsed += reviewUs;
      if (elapsed > budgetUs && review < 4000) {
        pieces += 1; // yield, then start a new piece
        elapsed = 0;
      }
    }
    console.log(`${pieces} pieces`);

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

      Exercise 2 · Warm-upWhich teas go first?

      What does this program print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // The file's order, and the teas on Asha's screen.
      const teas = ["rooibos", "oolong", "green-tea", "mint-tea"];
      const onScreen = (tea) => tea === "mint-tea" || tea === "green-tea";
      const first = teas.filter(onScreen);
      const rest = teas.filter((tea) => !onScreen(tea));
      console.log([...first, ...rest].join(", "));

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

        Exercise 3 · PracticeOne task on Ben's phone

        In the four-ways playground, pick one task. What was Total Blocking Time on Ben's phone, in ms?

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

          Exercise 4 · PracticeThe first rating Asha saw

          In the frames playground, pick file order. When did the first frame show any of Asha's ratings, in ms after reviews.js started?

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

            Exercise 5 · ChallengeRed triangles

            Open the course's trace of the 50 ms budget page in Chrome's DevTools. How many of reviews.js's tasks does DevTools mark with a red triangle?

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

              Exercise 6 · ChallengeReact's budget

              React has its own scheduler. In its settings file, how many ms does it work before it yields?

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

                Check your understanding

                For each question, ask what decides a piece's length, what goes first, and whether the work has to run on the phone at all.

                The breaking up long work quiz · 7 questionsScore: first tries count
                1. Question 1 of 7reviews.js yields after every 100 reviews. What changes on a phone twice as slow?

                  Choose an answer to see the explanation.

                2. Question 2 of 7What does this budget check print?

                  Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                  // Each review takes 3 ms. When does a 10 ms budget make the loop yield?
                  let elapsed = 0;
                  let read = 0;
                  while (elapsed <= 10) {
                    elapsed += 3; // one review
                    read += 1;
                  }
                  console.log(`Yield after ${read} reviews, ${elapsed} ms`);

                  Choose an answer to see the explanation.

                3. Question 3 of 7Why did a 50 ms budget make most pieces run just over 50 ms?

                  Choose an answer to see the explanation.

                4. Question 4 of 7With the screen's teas first, Asha's six ratings were ready well before they appeared. Why the gap?

                  Choose an answer to see the explanation.

                5. Question 5 of 7cart.js builds its price list idle until urgent. Asha clicks Add before any idle time has come. What happens?

                  Choose an answer to see the explanation.

                6. Question 6 of 7Every visitor gets the same ratings. Which change takes the work off their phones?

                  Choose an answer to see the explanation.

                7. Question 7 of 7Why does Chrome's own guide say not to use navigator.scheduling.isInputPending()?

                  Choose an answer to see the explanation.

                Key takeaways

                • A count fixes the number of pieces. The phone decides their length.
                • A time budget fixes the length. The phone decides how many pieces there are.
                • A piece ends a little past its budget. With 50 ms, most tasks were just over the long-task line.
                • Do the work the visitor can see first, then let a frame through so it shows.
                • Idle until urgent: build a value in idle time, and at once if a click needs it first.
                • The fastest work is skipped work: do it once on the server, remember results, and leave off-screen work for later.
                • Don't rely on isInputPending(). Yield on a budget, whether or not anyone clicks.

                Remember the one-liner.
                Cut work by the clock, show what's on screen first, prepare the rest in quiet moments, and skip what you can.

                Sources

                The facts on this page come from these specs, docs and source files:

                Coming next: Web workers for performance. Some work has to run, and can't be skipped. A worker runs it on another thread, away from Asha's clicks.

                CompleteFrontend Clear concepts. Working examples.