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.
- 01Cut 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.
- 02Put visible work firstDo the work for what's on screen first, then let a frame through, so the visitor sees it.
- 03Prepare 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.
// 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.
screenis when Asha's six ratings appeared.allis when all 40 had. - Line 7 prints
Every 100 reviews: her six at 680.6 ms, all 40 at 680.6 ms. Then it printsScreen 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.
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.
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
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:
- Cut the work by time, not by count.
- Do the work Asha can see first.
- Prepare a value in idle time, but at once if a click needs it.
- 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: 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:
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:
// 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 msandBen'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.
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:
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:
// 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 msand50 ms, Ben's phone: 20 pieces of about 50.8 ms. Then it prints10 ms, Asha's phone: 67 pieces of about 10.5 msand10 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.
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:
| What was measured | Asha's phone | Ben's phone |
|---|---|---|
| Tasks for the ratings | 40 | 40 |
| A typical task | 16.7 ms | 25.9 ms |
| The longest task | 20.5 ms | 34.9 ms |
| Total Blocking Time | 0 ms | 0 ms |
| All reviews read after | 673.9 ms | 1,061.9 ms |
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.
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()andscheduler.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:
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().topis how far the row's top is from the top of the window, in pixels.innerHeightis the window's height. - Lines 2 and 3 split the teas in two.
filterkeeps the file's order inside each group. - Line 4 reads the screen's teas first, then the rest. It uses the 10 ms budget.
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:
// 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.
readyis when the last of her six ratings was written.seenis 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 isThe screen's teas first: ready at 106.2 ms, on screen at 139.2 ms. Last isThe 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:
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,requestAnimationFrameruns 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.
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:
131.1 ms0 of 6 ratings on screen, 700 reviews read239.2 ms0 of 6 ratings on screen, 1,300 reviews read346.3 ms0 of 6 ratings on screen, 1,900 reviews read454 ms0 of 6 ratings on screen, 2,500 reviews read561.2 ms0 of 6 ratings on screen, 3,100 reviews read669.5 ms2 of 6 ratings on screen, 3,600 reviews read732.7 ms6 of 6 ratings on screen, 4,000 reviews 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.
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.
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: 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
makein 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.
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 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.
waitedis how long the click waited for the main thread.ranis how long its two handlers ran.totalis the part spent inaddToTotal. - Line 7 prints
While the reviews were read: waited 11.8, ran 61.2, 34.4 of it for the total. Then it printsOnce 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.
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:
// 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.Press Start. Then click Add while the stand-in load runs, or after it's done. Compare how long your click's handler runs.
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:
<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.
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.
| Version | Tasks for the ratings | Longest task | TBT | Screen's ratings appeared | All ratings appeared |
|---|---|---|---|---|---|
| One task | 1. | 651.8 ms. | 601.8 ms. | 1,678.2 ms. | 1,678.2 ms. |
| A piece per tea: every 100 reviews | 40. | 20.5 ms. | 0 ms. | 1,698.3 ms. | 1,698.3 ms. |
| A 50 ms budget | 14. | 56.9 ms. | 15.5 ms. | 1,726.7 ms. | 1,726.7 ms. |
| A 10 ms budget, the file's order | 67. | 15.6 ms. | 0 ms. | 1,755.3 ms. | 1,755.3 ms. |
| The screen's teas first | 65. | 17.3 ms. | 0 ms. | 1,162.7 ms. | 1,732.8 ms. |
| The screen's teas first, then a frame | 64. | 16.3 ms. | 0 ms. | 1,123 ms. | 1,714.7 ms. |
| Ratings worked out on the server | 0. | 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:
// 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
Mapto 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 printsWorked 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:
- 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
Sort each job by the fix that suits it best.
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.
This program models a 10 ms budget on Ben's phone. What does it print?
// 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`);103 pieces. Each piece reads 39 reviews, then yields. 102 pieces read 3,978 reviews, and a 103rd reads the last 22. The real recording on Ben's phone made 96: real reviews didn't all take 260 µs.
What does this program print?
// 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(", "));green-tea, mint-tea, rooibos, oolong. The screen's teas come first, but each group keeps the file's order.
In the four-ways playground, pick one task. What was Total Blocking Time on Ben's phone, in ms?
941.6 ms. One task of nearly a second, almost all of it past the 50 ms line.
In the frames playground, pick file order. When did the first frame show any of Asha's ratings, in ms after reviews.js started?
669.5 ms. In the file's order, that frame showed only 2 of her 6 ratings. The other four came in the last frame.
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?
13 of the 14. Every piece but the last ran past 50 ms, so DevTools marked it. The last piece was short: it read the reviews left over.
React has its own scheduler. In its settings file, how many ms does it work before it yields?
5 ms. React's scheduler works until 5 ms have passed, then yields. Its own comment says it yields "multiple times per frame".
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.
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.
Question 2 of 7What does this budget check print?
Read the code, then predict// 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.
Question 3 of 7Why did a 50 ms budget make most pieces run just over 50 ms?
Choose an answer to see the explanation.
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.
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.
Question 6 of 7Every visitor gets the same ratings. Which change takes the work off their phones?
Choose an answer to see the explanation.
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:
- Optimize long tasks, web.dev.
- Scheduler: yield() method, MDN.
- Scheduling: isInputPending() method, MDN.
- Is-Input-Pending, Chrome Platform Status.
- Prioritized Task Scheduling, WICG.
- Element: getBoundingClientRect() method, MDN.
- Optimize Interaction to Next Paint, web.dev.
- Idle Until Urgent, Philip Walton.
- idlize: IdleValue, GoogleChromeLabs on GitHub.
- Window: requestIdleCallback() method, MDN.
- content-visibility, MDN.
- Intersection Observer API, MDN.
- WarningsHandler.ts, Chrome DevTools, ChromeDevTools on GitHub.
- SchedulerFeatureFlags.js, React 19.3.1, facebook/react on GitHub.
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.