completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Yielding to the main thread

How scheduler.yield, scheduler.postTask, setTimeout, MessageChannel and requestIdleCallback give the browser a turn, and what each one lets go first.

By the end, you can
  • 01
    Explain what a yield gives the browserSay why a click gets in between pieces of work, and what else may run before the next piece.
  • 02
    Pick a way to yieldChoose between scheduler.yield, postTask's three priorities, setTimeout, MessageChannel and requestIdleCallback.
  • 03
    Yield in every browserDetect scheduler.yield, fall back when it's missing, and avoid setTimeout's 4 ms wait.

Why did Ravi's reviews finish later?

Ravi took the advice from lesson 27.1. reviews.js no longer reads all 4,000 reviews in one go. It reads 100, then steps aside, then reads 100 more. To step aside, it waits for a timer set to 0 ms, with setTimeout.

Asha opens the Leaf and Kettle tea list. While the reviews are being read, she clicks Add on Mint tea. This time the cart answers almost at once. But Ravi notices two new things. The line that counts the reviews fills up later than before. And the shop's analytics.js now runs in between his pieces of work.

So Ravi asks: is there a better way to step aside? One that lets Asha's click in, but doesn't push his own work to the back?

Start with Asha's click, on the old page and the new one:

One click, two versions of the pagePop out in the code editor (opens in a new tab)JavaScript
// Asha clicks Add 250 ms after reviews.js arrives. Times in ms since the page started.const pages = [  { name: "One task", clicked: 1270.6, cartShown: 1494.6, reviewsDone: 1418.3 },  { name: "setTimeout", clicked: 1267, cartShown: 1311.2, reviewsDone: 1750.7 },];for (const page of pages) {  console.log(`${page.name}: Cart: 1 showed ${(page.cartShown - page.clicked).toFixed(1)} ms after the click.`);  console.log(`${page.name}: the reviews were done at ${page.reviewsDone} ms.`);}
  • Line 1 is a comment. Times are in milliseconds (ms) since the page started. A millisecond is a thousandth of a second.
  • Lines 3 and 4 hold one load of each page. clicked is when she clicked. cartShown is the first frame that showed Cart: 1. reviewsDone is when the last review was read.
  • Lines 7 and 8 print two lines per page: One task: Cart: 1 showed 224.0 ms after the click. and One task: the reviews were done at 1418.3 ms. Then setTimeout: Cart: 1 showed 44.2 ms after the click. and setTimeout: the reviews were done at 1750.7 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. One task: at the click, 1,270.6 ms, Cart: 0; at 1,494.6 ms, Cart: 1 and all 4,000 reviews read. setTimeout: at the click, 1,267 ms, Cart: 0 and 1,100 read; at 1,311.2 ms, Cart: 1 and 1,300 read; at 1,763.5 ms, all 4,000 read.One taskclick, 1,270.6 msThe tea list with Cart: 0 and no reviews read yetCart: 01,494.6 msThe tea list with Cart: 1 and Reviews read: 4000 of 4000Cart: 1, 4,000 readsetTimeoutclick, 1,267 msThe tea list with Cart: 0 and Reviews read: 1100 of 4000Cart: 0, 1,100 read1,311.2 msThe tea list with Cart: 1 and Reviews read: 1300 of 4000Cart: 1, 1,300 read1,763.5 msThe tea list with Cart: 1 and Reviews read: 4000 of 4000Cart: 1, 4,000 read
Real frames from the course's recording, in ms since the page started. With setTimeout, Cart: 1 came 44.2 ms after the click instead of 224 ms. But the last review was read 332.4 ms later.

The click got its answer about five times sooner. But the last review was read 332.4 ms later. Why it matters: splitting the work helped the click. How it was split decided what happened to everything else.

Why would anything else change? The page's JavaScript runs on the main thread: the one thread that also handles clicks and draws frames. It runs one task at a time, from start to end. When a task ends, the browser picks the next one. With setTimeout, Ravi's next piece is a new task at the back of the line.

Real-life analogyGoing to the back of the queue

Meera brings forty parcels to the post office. To be polite, she hands over one parcel. Then she goes to the back of the queue and waits her turn again. A man with one stamp gets served quickly. But so does everyone who joined the queue after her. Meera leaves much later than if she had kept her place.

In real life: The post office clerk
In the browser: The main thread
In real life: Handing over one parcel
In the browser: One piece of reviews.js's work
In real life: Going to the back of the queue
In the browser: setTimeout(resolve, 0): the next piece waits at the end of the line
In real life: The man with one stamp
In the browser: Asha's click
In real life: People who joined the queue after Meera
In the browser: analytics.js's notes
The main idea

To yield is to stop your work for a moment, so the browser can run something else. Every way of yielding lets a click in. They differ in what else goes first: other scripts, frames, or your own next piece. So the choice is about order, not speed.

The browser picks each next task with its event loop: a loop that takes one task, runs it to the end, then picks the next. The JavaScript lesson Keeping the main thread responsive shows it with small programs. This lesson measures it, in five steps:

  1. Why yielding helps at all.
  2. Why setTimeout sends Ravi to the back.
  3. How scheduler.yield keeps his place.
  4. What scheduler.postTask's three priorities change.
  5. When requestIdleCallback runs, and when it doesn't.

How the course recorded it

Here is reviews.js on the setTimeout page:

reviews.js, yielding with setTimeoutJavaScript
// reviews.js: reads this week's 4,000 reviews, 100 at a time, and yields after each piece.const about = document.querySelector("#about");const yieldNow = () => new Promise((resolve) => setTimeout(resolve, 0));async function readReviews() {  for (let read = 100; read <= 4000; read += 100) {    work(100 * 23_000);    about.textContent = "Reviews read: " + read + " of 4000";    await yieldNow();  }}readReviews();
  • Line 3 makes yieldNow. It returns a promise: an object that stands for a value that comes later. This one is settled when a 0 ms timer fires.
  • Lines 5 to 9 read 100 reviews at a time. Line 6 does the work: 23,000 steps for each review, with work() from lesson 26.3. That's about 10 ms per piece on Asha's phone.
  • Line 7 shows how many reviews have been read.
  • Line 8 yields. await pauses readReviews until the timer fires. The rest of the function then runs in a new task. That rest is called the continuation.

Four other pages change only line 3. And here is analytics.js, on every page:

analytics.jsJavaScript
// analytics.js: every 50 ms, packs a short note about the visit.let notes = 0;setInterval(function sendNote() {  work(2_000_000);  notes += 1;}, 50);
  • Line 3 sets an interval timer. It runs sendNote every 50 ms.
  • Line 4 does 2 million steps of work. That's about 9 ms on Asha's phone.

It stands in for the other scripts a real shop runs: analytics, ads, chat widgets.

  • Eight pages: the same reviews.js work, as one task, or yielding in one of seven ways.
  • Asha's phone: this computer in Chrome 157, with its processor, the CPU, slowed 4 times, as in lesson 27.1. The server held reviews.js back 1 second, so the list was on screen first.
  • Clicks: one click on Add, Mint tea, in each load. It came 100, 250 or 400 ms after the server sent reviews.js. That's 24 loads. For each, Chrome kept a trace: a detailed record of everything it did, with screenshots. The lesson quotes the loads clicked at 250 ms.
  • How the clicks were made: a mouse press and release, sent through Chrome's DevTools protocol, the way programs drive Chrome. Chrome passes these straight to the page, as it does a real mouse. They aren't held for a frame, as lesson 27.1's touches were.

Why yielding helps

Lesson 27.1 found the rule. A click waits for the rest of whatever task is running. A yield ends the task early. So a click waits for one piece at most, not the whole job.

Here are the longest waits of Asha's three clicks, on each page:

The longest wait for a clickPop out in the code editor (opens in a new tab)JavaScript
// Asha's clicks at three moments on each page: the longest wait before her click ran. In ms.const longest = {  "one task": 307.1,  setTimeout: 9.6,  "scheduler.yield": 12.5,  "user-blocking": 10.6,  "user-visible": 11.2,  background: 4.7,  MessageChannel: 10.6,  "idle callback": 10.7,};const yielding = Object.entries(longest).filter(([name]) => name !== "one task");console.log(`One task: ${longest["one task"]} ms`);console.log(`Yielding, any of ${yielding.length} ways: ${Math.max(...yielding.map(([, ms]) => ms))} ms at most`);
  • Lines 3 to 10 hold each page's longest wait, of three clicks.
  • Line 12 keeps the seven pages that yield.
  • Lines 13 and 14 print One task: 307.1 ms and Yielding, any of 7 ways: 12.5 ms at most.

A piece took about 10 ms. So a click never waited much longer than one piece. Why it matters: for the click itself, every way of yielding works. The differences are in everything else.

What else? A yield leaves a gap between two pieces. The browser fills that gap with whatever it thinks should go first. Here are the first 200 ms of every page, side by side:

The first 200 ms after reviews.js started, on each page, to scale. one task: 1 pieces and 0 notes; all done after 398.2 ms. setTimeout: 10 pieces and 4 notes; all done after 731.6 ms. scheduler.yield: 15 pieces and 0 notes; all done after 503.4 ms. user-blocking: 15 pieces and 0 notes; all done after 501.3 ms. user-visible: 10 pieces and 4 notes; all done after 662.8 ms. background: 10 pieces and 4 notes; all done after 668.5 ms. MessageChannel: 10 pieces and 4 notes; all done after 665.2 ms. idle callback: 6 pieces and 4 notes; all done after 618.8 ms.reviews.js: a pieceanalytics.js: a noteall doneone task398.2 mssetTimeout731.6 msscheduler.yield503.4 msuser-blocking501.3 msuser-visible662.8 msbackground668.5 msMessageChannel665.2 msidle callback618.8 msreviews.js starts100 ms200 ms
The first 200 ms of each page, from the course's recording. Shaded bars are Ravi's pieces, plain bars are analytics.js's notes, and gaps are frames or time with nothing to do. The right column says when the last review was read.

Read the rows from the top. With one task, nothing else ran. With setTimeout, plain notes sit between the shaded pieces. With scheduler.yield and user-blocking, the pieces run back to back. The right column shows the cost. The last review was read after 398.2 ms with one task, 731.6 ms with setTimeout, and 503.4 ms with scheduler.yield.

Pick a way below. The list shows the main thread's first tasks after reviews.js started, in order:

Playground: what ran between Ravi's pieces
The main thread, in ordersetTimeout
  1. reviews.js: piece 117 ms
  2. Chrome: a frame38.1 ms
  3. reviews.js: piece 211.3 ms
  4. analytics.js: a note8.6 ms
  5. reviews.js: piece 310.7 ms
  6. Chrome: a frame1.3 ms
  7. analytics.js: a note9.4 ms
  8. reviews.js: piece 410.9 ms
  9. Chrome: a frame1.3 ms
  10. reviews.js: piece 510.9 ms
  11. reviews.js: piece 610.9 ms
  12. Chrome: a frame1.9 ms
  13. reviews.js: piece 710.8 ms
  14. analytics.js: a note8.4 ms
Try it yourself
setTimeout(resolve, 0): reviews done after 731.6 ms

The work ran in 40 tasks. 14 analytics notes ran in between. Chrome drew 36 frames meanwhile, and the longest stretch without one was 69.5 ms. Asha's click waited 9.6 ms.

From the course's recording in Chrome 157, with the CPU slowed 4 times: the load where Asha clicked 250 ms after reviews.js arrived. The list shows the main thread's first tasks after reviews.js started, in order, leaving out the browser's own tiny ones.

Why it matters: whatever the browser puts in your gaps delays the rest of your work.

setTimeout sends Ravi to the back of the line

setTimeout(fn, 0) asks the browser to run fn in a new task, after a wait of 0 ms. That sounds like "right away". It isn't, for two reasons.

First, the new task joins the back of the line. Like Meera at the post office, it waits behind every task of the same kind that is already due. analytics.js's notes are timer tasks too. So each note that came due during a piece ran before Ravi's next piece.

Here is how the main thread spent the setTimeout page's 731.6 ms:

Where the setTimeout page's time wentPop out in the code editor (opens in a new tab)JavaScript
// The setTimeout page: how the main thread spent the time from the first piece to the last. In ms.const spent = {  "reading reviews": 428.7,  "analytics notes": 119.8,  "drawing frames": 92.2,  "Asha's click": 30.5,  "nothing at all": 56,};const total = 731.6;for (const [what, ms] of Object.entries(spent)) {  console.log(`${what}: ${ms} ms, ${Math.round((ms / total) * 100)}%`);}
  • Lines 3 to 7 hold how long the main thread spent on each thing, from the trace.
  • Line 11 prints each one, with its share of the total.
  • It prints reading reviews: 428.7 ms, 59%, analytics notes: 119.8 ms, 16%, drawing frames: 92.2 ms, 13%, Asha's click: 30.5 ms, 4% and nothing at all: 56 ms, 8%.

The last 4.6 ms went on the browser's own small tasks. Reading took about as long as it did in one task. The rest is new: 14 notes and 36 frames got in between. And for 56 ms, the main thread did nothing at all. Why it matters: every gap you leave can be filled, or simply lost.

Why the main thread sat idle

That's the second reason. The HTML standard has a rule for timers set inside timers. It counts how deeply they are nested. In its words: "If nestingLevel is greater than 5, and timeout is less than 4, then set timeout to 4."

reviews.js set its first timer from the script itself, not from a timer. Each later timer was set inside the one before. Chrome wrote down the wait it gave each one in the trace:

The wait Chrome set for each timerPop out in the code editor (opens in a new tab)JavaScript
// The setTimeout page: the wait Chrome set for each of reviews.js's 40 timers, from the trace. In ms.const waits = [  0, 0, 0, 0, 0, 0, 4, 4, 4, 4,  4, 4, 4, 4, 4, 4, 4, 4, 4, 4,  4, 4, 4, 4, 4, 4, 4, 4, 4, 4,  4, 4, 4, 4, 4, 4, 4, 4, 4, 4,];const first = waits.indexOf(4) + 1;console.log(`Timers 1 to ${first - 1}: 0 ms`);console.log(`Timers ${first} to ${waits.length}: ${waits.length - first + 1} timers, 4 ms each`);
  • Lines 3 to 6 hold the 40 waits, ten to a line.
  • Line 8 finds the first 4.
  • Lines 9 and 10 print Timers 1 to 6: 0 ms and Timers 7 to 40: 34 timers, 4 ms each.

Each of those 34 timers waited at least 4 ms, even when nothing else wanted the main thread. It's like a lift that, after six quick trips, waits with its doors open before each new one. Chrome's own code counts levels from 1, not 0. So it checks for a level above 6: the same rule.

Try it in your browser. Run sets ten timers in a chain, then sends ten messages:

Playground: ten timers in a row, in your browser
What Run doesJavaScript
// What Run does: ten timers in a chain, then ten messages, each timed from asking to running.const waits = [];for (let i = 0; i < 10; i++) {  const asked = performance.now();  await new Promise((resolve) => setTimeout(resolve, 0));  waits.push(performance.now() - asked);}
Waits, in orderNot run yet
Try it yourself
Press Run

Run sets ten 0 ms timers, each inside the one before, then sends ten messages the same way. It times each one, from asking to running.

Real timers and messages on this page, timed by your browser. Numbers change from run to run, and other work on the page can make any one of them wait longer.

MessageChannel: a new task with no timer

A MessageChannel is a pair of connected ports. A message posted to one port arrives at the other as a new task. There's no timer, so there's no 4 ms rule. The scheduling spec calls it "a well-known workaround". Here is the MessageChannel page's yieldNow:

Yielding with a MessageChannelJavaScript
const channel = new MessageChannel();const yieldNow = () => new Promise((resolve) => {  channel.port1.onmessage = () => resolve();  channel.port2.postMessage("go");});
  • Line 1 makes the channel, with its two ports.
  • Line 3 settles the promise when port1 receives a message.
  • Line 4 posts a message from port2. It arrives as a new task.

On this page, the main thread sat idle for just 3.5 ms. The reviews were done after 665.2 ms, 66.4 ms sooner than with setTimeout. But notes still got in. In Chrome, a message waits at the same priority as a timer. Why it matters: MessageChannel yields without the 4 ms waits, but it still goes to the back of the line.

scheduler.yield keeps Ravi's place

scheduler.yield() is made for yielding. It returns a promise, and the browser settles it in a new task. So await scheduler.yield() pauses the function and carries on later, as Ravi's timer did. The difference is where the continuation waits.

Real-life analogyKeeping your place in the queue

This time Meera hands over a parcel and steps to the side. The clerk serves the man with one stamp. Then the clerk waves Meera back to the counter, ahead of the rest of the queue. She still lets the urgent customer in. She just doesn't lose her place.

In real life: Stepping to the side after a parcel
In the browser: await scheduler.yield()
In real life: The man with one stamp, served first
In the browser: Asha's click: input still goes first
In real life: Being waved back to the counter
In the browser: The continuation runs before other tasks like it
In real life: The rest of the queue
In the browser: analytics.js's notes

On the scheduler.yield page, one line of reviews.js changes:

The scheduler.yield page's yieldNowJavaScript
// reviews.js on the scheduler.yield page: line 3 is the only change.
const yieldNow = () => scheduler.yield();

Here are the two pages, side by side:

setTimeout against scheduler.yieldPop out in the code editor (opens in a new tab)JavaScript
// The same work, two ways of yielding, from the recording. Times in ms.const pages = [  { name: "setTimeout", done: 731.6, notesInBetween: 14, frames: 36 },  { name: "scheduler.yield", done: 503.4, notesInBetween: 0, frames: 6 },];for (const page of pages) {  console.log(`${page.name}: done in ${page.done} ms, ${page.notesInBetween} notes in between, ${page.frames} frames`);}
  • Lines 3 and 4 hold each page's recorded load.
  • Line 7 prints setTimeout: done in 731.6 ms, 14 notes in between, 36 frames and scheduler.yield: done in 503.4 ms, 0 notes in between, 6 frames.

No note ran until Ravi was done. The reviews finished 228.2 ms sooner. And Asha's click still waited only 10.5 ms. Why it matters: scheduler.yield gives the main thread back for urgent work, not to every other script.

Why the continuation goes first

A priority is a label for how urgent a task is. The Prioritized Task Scheduling spec gives one to each task it schedules. It also says: "Continuations have a higher effective priority than tasks with the same TaskPriority." Chrome's guide puts it plainly: the continuation runs "before running any other similar tasks that have been queued up by the page."

Chrome turns these labels into one order. Here it is, from the source code of Chrome's scheduler:

Chrome's order when several tasks are waiting, from first to last: Input, and the frame that answers it; A frame the screen has waited too long for; postTask "user-blocking"; scheduler.yield() continuations; setTimeout, MessageChannel, "user-visible"; postTask "background"; Idle callbacks: only in idle time.firstlastInput, and the frame that answers itA frame the screen has waited too long forpostTask "user-blocking"scheduler.yield() continuationssetTimeout, MessageChannel, "user-visible"postTask "background"Idle callbacks: only in idle time
Chrome's order, from its scheduler's source code. Inside a postTask row, a continuation goes before a new task. Ordinary frames share the fifth row.

Input sits at the top, so Asha's click got in between two pieces. Timers sit one row below scheduler.yield's continuations, so the notes waited. Why it matters: when several tasks are waiting, this order decides which one runs.

Fewer frames while the work runs

Look at the frames again. With setTimeout, Chrome drew 36 frames while the reviews were read. With scheduler.yield, it drew 6. The longest stretch without a new frame was 144.1 ms. Ordinary frames wait in the same row as timers. Chrome lets a frame jump ahead only once the screen has waited too long.

Asha's click wasn't hurt. The frame that answers input sits at the top. Chrome's Event Timing API, which times each interaction, gave her click 40 ms on both pages. But the reviews line jumped in big steps. Why it matters: if your work should move something on screen as it goes, check for long animation frames, from lesson 27.1.

When the browser has no scheduler.yield

Chrome has scheduler.yield from version 129, and Firefox from version 142. Safari doesn't have it. So check for it first, and keep a fallback:

scheduler.yield, with a fallbackJavaScript
// Yield with scheduler.yield() where the browser has it, or with a timer where it doesn't.function yieldNow() {  if (globalThis.scheduler?.yield) return scheduler.yield();  return new Promise((resolve) => setTimeout(resolve, 0));}
  • Line 3 checks for scheduler and for its yield method. web.dev warns to check both.
  • Line 4 is the fallback: a timer, as on Ravi's first page.

Checking first like this is called feature detection. In Safari, the page still yields, so clicks still get in. As web.dev puts it, such browsers "won't get the prioritized continuation, they will still yield for the browser to stay responsive." A MessageChannel would also work as the fallback, without the 4 ms waits.

scheduler.postTask and its three priorities

scheduler.postTask(callback, options) queues a new task with a priority. It returns a promise that settles once the task has run. The spec defines three priorities:

  • "user-blocking": the highest. For work that answers input, or updates what's on screen.
  • "user-visible": the default. For work the user will see, but isn't waiting on.
  • "background": the lowest. The spec's examples are "background log or metrics processing or initializing certain third party libraries".
Real-life analogyBoarding a plane by groups

At the gate, staff call passengers by group. Group 1 boards first, then group 2, then group 3. Anyone who needs help boards before all of them. Inside a group, it's first come, first served.

In real life: Passengers who need help, first of all
In the browser: Input, like Asha's click
In real life: Group 1
In the browser: "user-blocking"
In real life: Group 2
In the browser: "user-visible", where timers also wait
In real life: Group 3
In the browser: "background"
In real life: First come, first served inside a group
In the browser: The older task of the same priority goes first

Three pages yield with postTask. Each has its own priority:

Three prioritiesJavaScript
// The yieldNow line on each postTask page. Only the priority changes.const urgentPage = () => scheduler.postTask(() => {}, { priority: "user-blocking" });const normalPage = () => scheduler.postTask(() => {}, { priority: "user-visible" });const laterPage = () => scheduler.postTask(() => {}, { priority: "background" });
  • Each line posts an empty task. When it has run, its promise settles, and readReviews carries on.
  • Only the priority differs: line 2 is the highest, line 4 the lowest.
The three postTask pagesPop out in the code editor (opens in a new tab)JavaScript
// The three postTask pages, from the recording. Times in ms.const pages = [  { priority: "user-blocking", done: 501.3, notesInBetween: 0 },  { priority: "user-visible", done: 662.8, notesInBetween: 13 },  { priority: "background", done: 668.5, notesInBetween: 13 },];for (const page of pages) {  console.log(`${page.priority}: done in ${page.done} ms, ${page.notesInBetween} notes in between`);}
  • Lines 3 to 5 hold each page's recorded load.
  • Line 8 prints user-blocking: done in 501.3 ms, 0 notes in between. Then comes user-visible: done in 662.8 ms, 13 notes in between. Last is background: done in 668.5 ms, 13 notes in between.

user-blocking behaved like scheduler.yield. Its row sits above the timers. user-visible shares a row with timers, so each note that was already due went first. background sits below the timers, so every waiting note went first.

So why did background finish just 5.7 ms after user-visible? There was little else to wait for. A note came due only every 50 ms. Why it matters: a priority only changes the order when tasks compete. On a busier page, background work can wait much longer.

Try the race on this page. A noise timer stands in for analytics.js:

Playground: yield on this page
What Run doesJavaScript
// What Run 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 noise = setInterval(() => busy(2), 20);for (let piece = 1; piece <= 20; piece++) {  busy(8);  await yieldNow(); // the way you picked; "One task" skips this line}clearInterval(noise);
Your taps0

Not run yet.

Try it yourself
What your browser did

Pick a way, press Run, then press Tap me while it runs. Run does 20 pieces of 8 ms, while another timer does 2 ms of work every 20 ms.

Real tasks on this page's main thread, timed by your browser. Numbers change from run to run and from computer to computer.

A yield inside postTask keeps the task's priority

scheduler.yield doesn't always use "user-visible". The spec says the continuation's priority "and the signal used to abort it are inherited from the originating task." So a yield inside a background task stays background:

A background job that yieldsJavaScript
// A background job that yields. Its continuations stay "background".scheduler.postTask(async () => {  for (const note of notes) {    send(note);    await scheduler.yield();  }}, { priority: "background" });
  • Line 2 queues the whole job, and line 7 gives it the priority "background".
  • Line 4 sends one note. Line 5 yields between notes.

Each continuation goes ahead of other background tasks, but behind everything else. Chrome's guide says it "remains 'background' work." Why it matters: you can split low-priority work without it jumping the queue.

requestIdleCallback waits for quiet time

requestIdleCallback(callback) runs callback in an idle period: a stretch when the browser has nothing else to do. It passes the callback a deadline object. deadline.timeRemaining() says how many ms of the idle period are left. The spec caps an idle period at 50 ms. So a click that arrives still has time for an answer.

Real-life analogyRestocking shelves between customers

Ravi restocks the tea shelves only when no customer is at the till. Before each box, he glances at the clock. The next delivery is due in ten minutes, so he keeps going. If a customer walks in, he finishes the box in his hands first.

In real life: No customer at the till
In the browser: An idle period
In real life: Glancing at the clock before each box
In the browser: deadline.timeRemaining()
In real life: The next delivery, due soon
In the browser: The next frame or timer, which ends the idle period
In real life: Finishing the box in his hands
In the browser: A piece that has started runs to the end

The idle-callback page reads the reviews this way:

reviews.js, in idle timeJavaScript
// reviews.js: reads this week's 4,000 reviews, 100 at a time, when the browser is idle.const about = document.querySelector("#about");let read = 0;function readSome(deadline) {  while (read < 4000 && deadline.timeRemaining() > 0) {    work(100 * 23_000);    read += 100;    about.textContent = "Reviews read: " + read + " of 4000";  }  if (read < 4000) requestIdleCallback(readSome);}requestIdleCallback(readSome);
  • Line 4 gets the deadline.
  • Line 5 keeps reading while reviews are left and the idle period has time left.
  • Lines 6 to 8 read one piece and show the count.
  • Line 10 asks for another idle period if reviews are left. Line 12 asks for the first one.
What the deadline gavePop out in the code editor (opens in a new tab)JavaScript
// Three idle callbacks on the idle-callback page, from the trace. Times in ms.const callbacks = [  { given: 38.7, ran: 42.1, pieces: 4 },  { given: 2.9, ran: 10.4, pieces: 1 },  { given: 38.3, ran: 47.8, pieces: 5 },];for (const call of callbacks) {  console.log(`Given ${call.given} ms, it read ${call.pieces * 100} reviews and ran ${call.ran} ms.`);}
  • Lines 3 to 5 hold three idle callbacks from the trace: the time Chrome gave each, and how long each ran.
  • Line 8 prints Given 38.7 ms, it read 400 reviews and ran 42.1 ms., then Given 2.9 ms, it read 100 reviews and ran 10.4 ms. and Given 38.3 ms, it read 500 reviews and ran 47.8 ms.

Look at the second one. It got 2.9 ms, which is more than 0, so a whole piece started. That piece ran 10.4 ms. timeRemaining() only tells you what's left. It doesn't stop your code, just as Ravi finishes his box.

In two of the three loads, the idle-callback page finished. In the load quoted here, the last review was read after 618.8 ms. In the third load, it never finished. After Asha's click, Chrome didn't call readSome again. Six seconds later, the line still said 400 of 4000.

MDN warns about this: without a timeout, "it's possible multiple seconds will elapse before the callback is fired." A timeout option, like { timeout: 2000 }, makes the browser run the callback once that time has passed, idle or not. Why it matters: idle callbacks suit work nobody is waiting for. The reviews line isn't that kind of work.

Here is every page, side by side. Read each row as one way to run the work, in the load clicked at 250 ms:

Seven ways to yield, and one task, as the recording saw them
Way to yieldReviews done afterAnalytics notes in betweenFrames drawn meanwhile
No yielding: one task398.2 ms.0.0.
setTimeout(resolve, 0)731.6 ms.14.36.
scheduler.yield()503.4 ms.0.6.
postTask, "user-blocking"501.3 ms.0.5.
postTask, "user-visible"662.8 ms.13.30.
postTask, "background"668.5 ms.13.34.
MessageChannel665.2 ms.13.28.
requestIdleCallback618.8 ms.12.18.

Which way should Ravi use?

For reviews.js, Ravi picks scheduler.yield, with the setTimeout fallback. Clicks get in, analytics waits, and the line is done 228.2 ms sooner than with setTimeout. For analytics.js, background or an idle callback would suit better. Each job below comes from a real shop page. Sort it by the way to yield that fits it best:

Which way should Ravi use?
  • Showing the teas Asha just searched for, in pieces
  • Reading this week's reviews for the line on screen
  • The rest of a slow click handler, after it shows a spinner
  • Packing analytics notes
  • Starting a chat widget nobody has opened yet
  • Filling a cache for the next page, sometime soon
  • Clearing out old notes saved in the browser
Try it yourself
0 of 7 correct

Sort each job by the way to yield that fits it best.

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

Common mix-ups

  • "setTimeout(fn, 0) runs fn right after the current task." No. It joins the back of the line. On the setTimeout page, 14 notes got in between Ravi's pieces. From the seventh timer on, each also waited 4 ms.
  • "scheduler.yield() lets everything else run first." No. It lets input in, and a frame that has waited too long. Other scripts' tasks waited until Ravi was done.
  • "A higher priority makes the work itself faster." No. Each piece still took about 10 ms. A priority only changes what runs in the gaps.
  • "background work runs on another thread." No. It runs on the same main thread, just later in the line. Moving work off the main thread needs a worker.
  • "timeRemaining() stops the callback when time is up." No. It only says how much time is left. Given 2.9 ms, a callback still ran a 10.4 ms piece.
  • "An idle callback always runs soon." No. In one load, it didn't run again for the rest of the recording. Give it a timeout if the work must happen.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upWhich timers wait?

This program models the HTML standard's timer rule. What does it print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// A click starts a chain of 8 timers. Each is set inside the one before, with 0 ms.
let nesting = 0; // the click isn't a timer
const waits = [];
for (let timer = 1; timer <= 8; timer++) {
  waits.push(nesting > 5 ? 4 : 0);
  nesting += 1; // the next timer is set inside this one
}
console.log(waits.join(" "));

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

    Exercise 2 · Warm-upWhat runs next?

    Ravi switches to postTask with "background". Three tasks are waiting. What does the program print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Ravi yields with postTask, "background". Chrome's ranks, from its source: the smaller runs first.
    const waiting = [
      { name: "an analytics note", rank: 7 },
      { name: "Ravi's next piece", rank: 9 },
      { name: "Asha's click", rank: 1 },
    ];
    waiting.sort((a, b) => a.rank - b.rank);
    console.log(waiting.map((task) => task.name).join(", then "));

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

      Exercise 3 · PracticeIdle callbacks it took

      In the order playground, pick idle callback. How many tasks did reading all 4,000 reviews take?

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

        Exercise 4 · PracticeA frame's longest wait

        In the order playground, pick user-visible. What was the longest stretch without a new frame, in ms?

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

          Exercise 5 · ChallengeWhat starts each piece?

          Open the course's trace of the background page in Chrome's DevTools. What does DevTools call the event right under each of Ravi's pieces after the first?

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

            Exercise 6 · ChallengeHow long is too long?

            In Chrome's scheduler, after how many ms without a frame does rendering get a higher priority?

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

              Check your understanding

              For each question, ask what is waiting in the line, and which row of Chrome's order each task sits in.

              The yielding quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Ravi yields with setTimeout(resolve, 0). An analytics timer came due while his piece ran. What runs next?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does scheduler.yield() let go ahead of Ravi's next piece?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does this program print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Ravi yields with scheduler.yield(). Chrome's ranks, from its source: the smaller runs first.
                const waiting = [
                  { name: "an analytics note", rank: 7 },
                  { name: "Ravi's next piece", rank: 6 },
                  { name: "Asha's click", rank: 1 },
                ];
                waiting.sort((a, b) => a.rank - b.rank);
                console.log(waiting.map((task) => task.name).join(", then "));

                Choose an answer to see the explanation.

              4. Question 4 of 7Which of reviews.js's chained timers was the first that Chrome made wait, though it asked for no wait at all?

                Choose an answer to see the explanation.

              5. Question 5 of 7Which priority does scheduler.postTask() use when you don't pass one?

                Choose an answer to see the explanation.

              6. Question 6 of 7requestIdleCallback hands your function a deadline. What is the most time an idle period can give it?

                Choose an answer to see the explanation.

              7. Question 7 of 7Safari has no scheduler.yield. What should reviews.js do there?

                Choose an answer to see the explanation.

              Key takeaways

              • A yield ends the task, so a click waits for one piece, not the whole job. Every way of yielding does that.
              • The ways differ in what goes into the gap: other scripts' tasks, frames, or your own next piece.
              • setTimeout and MessageChannel join the back of the line. Past five nested levels, setTimeout also waits 4 ms.
              • scheduler.yield keeps your place: its continuation goes before other tasks like it, after input.
              • postTask has three priorities: user-blocking, user-visible (the default) and background. A yield inside it keeps its priority.
              • requestIdleCallback runs only in idle time, for up to 50 ms. Give it a timeout if the work must happen.
              • Detect scheduler.yield, and fall back to setTimeout or MessageChannel where it's missing.

              Remember the one-liner.
              Yield so a click can get in, and pick the way that matches how urgent the rest of your work is.

              Sources

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

              Coming next: Breaking up long work. It shows how big each piece should be: work until a time budget runs out, then yield.

              CompleteFrontend Clear concepts. Working examples.