completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Scheduling work

Learn to schedule JavaScript work with idle periods, task priorities, yielding, input responsiveness, and Long Animation Frames.

By the end, you can
  • 01
    Choose a prioritySort work into urgent, visible, and background, instead of treating every task as equally important.
  • 02
    Break up long workSplit long work into small chunks and yield between them, so the page can handle clicks and paint.
  • 03
    Measure slow screen updatesUse Long Animation Frames where the browser has them, and know what scheduling tools cannot do.

Choose the next useful work

A web page has one main thread. A thread is a line of work that the computer runs one step at a time. On the main thread, the browser:

  • runs most of your JavaScript,
  • handles clicks and key presses,
  • and draws (paints) the page on the screen.

The main thread does one thing at a time. While your code runs, a click has to wait. The next paint of the screen has to wait too.

A task is one piece of work that the browser runs from start to end without stopping. One script, one click handler, and one timer callback are each a task. Task scheduling means two things:

  • deciding which task should run next;
  • deciding where long work may pause, so other work can have a turn.
Five orders, with a pause after eachPop out in the code editor (opens in a new tab)JavaScript
const yieldToMain = () =>
  globalThis.scheduler?.yield
    ? scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));

async function processOrders() {
  const orders = ["tea", "milk", "bread", "fruit", "rice"];
  for (const order of orders) {
    console.log("chunk", order);
    await yieldToMain();
  }
  console.log("done");
}

processOrders();

Line 1 makes a helper called yieldToMain. It returns a promise that resolves (finishes) a little later, in a new task. Lines 2 to 4 pick how. If the browser has scheduler.yield(), the helper uses it. If not, it uses a timer with a delay of 0.

Line 9 prints one order. Line 10 waits for the helper with await. The await pauses the function there and ends the current task. Line 12 prints done at the end. The output is chunk tea, chunk milk, chunk bread, chunk fruit, chunk rice, then done.

Top: all five orders, tea, milk, bread, fruit, and rice, run as one long task, so a click waits until the end. Bottom: each order is its own small chunk with a pause after it, so the click runs after the first chunk.one long tasktea, milk, bread, fruit, riceclickclick waitsa pause after each chunkteaclickmilkbreadfruitriceclick runs at a pause
Top: one long task, so the click waits. Bottom: the loop pauses after each order, so the click gets a turn after tea.

To yield means to give way. When the loop yields, it hands the main thread back to the browser for a moment. The browser can then handle a waiting click or paint the screen. Then the loop goes on with the next order.

Yielding does not make the work faster. It does not run the work at the same time as other work either. The loop itself picks where it stops. To move heavy work off the main thread completely, use a worker: a separate thread that runs JavaScript.

Pick stopping points where the page still makes sense. For example:

  • A cart can show how many items it has before it works out suggestions.
  • A search can show the text you typed before it checks every product.

Don't split a tiny job just because you can. Each extra task costs a little time too.

Real-life analogyA ticket counter queue

Picture a ticket counter with one clerk. Some people have an urgent problem. Some want an ordinary ticket. The clerk also has paperwork, which can wait until nobody is in line. If a person with a long request steps aside for a moment, someone urgent can be served first.

The three kinds of work have names in the browser. You will meet them in the priorities section below.

In real life: A person at the counter
In JavaScript: A task using the main thread
In real life: Someone with an urgent problem
In JavaScript: user-blocking work
In real life: A normal ticket request
In JavaScript: user-visible work
In real life: Paperwork when nobody waits
In JavaScript: background work

Idle periods and requestIdleCallback

An idle period is free time. It is a moment when the browser thinks it can run low-priority work without slowing down clicks or painting. requestIdleCallback(callback) asks the browser to run your callback in one of those free moments.

Use it for work nobody is waiting for. One example is filling a cache, a store of saved results for later. Don't use it for the result of a click: that result could wait a long time.

Ask how much free time is leftPop out in the code editor (opens in a new tab)JavaScript
if ("requestIdleCallback" in globalThis) {
  requestIdleCallback(
    (deadline) => {
      console.log("time left", deadline.timeRemaining() > 0);
      console.log("timed out", deadline.didTimeout);
    },
    { timeout: 1000 },
  );
} else {
  console.log("idle callback is not supported here");
}

Line 1 checks that the browser has requestIdleCallback before calling it. On line 3, the callback gets a deadline object (its type is called IdleDeadline).

  • Line 4 calls deadline.timeRemaining(). It says how many milliseconds of free time are left. The line prints whether that is more than 0.
  • Line 5 prints deadline.didTimeout. It is true when the callback ran because the timeout ran out, not because there was free time.
  • Line 7 sets that timeout to 1000 ms.

In a browser without this function, the code prints idle callback is not supported here.

A row of page time: busy page work, then free time, then busy page work again. requestIdleCallback points at the free time, where one small idle step runs.requestIdleCallback(…)page worksmall idle steppage workbusyfree timebusy
The browser runs your idle callback only in free time between its own work. If the page stays busy, the callback keeps waiting.

A busy page may have no free time at all. If the work must happen at some point, give it a timeout. The timeout has a cost: when it runs out, the callback runs even if the page is busy. So keep each idle step short. If more work is left, ask for another idle callback.

A one-line idle decisionPop out in the code editor (opens in a new tab)JavaScript
console.log(0 > 4 ? "run one small background step" : "wait for another idle period");

This one-line model prints wait for another idle period. No time is left, and 0 is not more than 4. A real deadline is not a fixed amount of time you are promised. It can shrink as the browser gets busy. So don't start a big loop just because some time is left.

Give a task a priority with postTask

A priority says how urgent a task is. Some browsers have scheduler.postTask(callback, options). It adds a new task and lets you give it one of three priorities:

  • user-blocking: someone is waiting for this right now, like the answer to a click.
  • user-visible: normal work the person can see. This is the default.
  • background: work nobody is waiting for. It waits behind the other two.
Urgent work runs before background workPop out in the code editor (opens in a new tab)JavaScript
if (globalThis.scheduler?.postTask) {
  scheduler.postTask(() => console.log("background"), {
    priority: "background",
  });
  scheduler.postTask(() => console.log("click"), {
    priority: "user-blocking",
  });
} else {
  console.log("postTask is not supported here");
}

Line 1 checks that the browser has scheduler.postTask. Line 2 posts a task that prints background, with background priority. Line 5 posts a task that prints click, with user-blocking priority. In Chrome, click prints first, even though it was posted second.

In a browser without this function, the code prints postTask is not supported here. Real apps need this check too, not only lessons.

Left, the order the tasks were posted: first background on line 2, then click on line 5. Right, the order they run in Chrome: click first because it is user-blocking, then background.posted order1. backgroundline 2: background2. clickline 5: user-blockingrun order1. clickmost urgent first2. backgroundwaits for the rest
background is posted first, on line 2. The click is posted later, but it is user-blocking, so Chrome runs it first.

A priority tells the browser how urgent work is. It is not a way to put everything first. If every task says user-blocking, background work never gets a fair turn. Ask one question: would the person notice a delay right now? If yes, the work is urgent or visible. If no, it is background work.

Step through: which task runs first
Step 0 of 4Ready
Your turn: follow the blue line

These steps replay a small model made for learning. It shows how priorities order tasks. It does not look inside a real browser.

Running in
  1. script
Next: line 1
  { name: "save analytics", priority: "background", posted: 1 },  { name: "show cart", priority: "user-visible", posted: 2 },  { name: "handle click", priority: "user-blocking", posted: 3 },]; console.log(orderTasks(tasks).join(" -> "));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Playground: change the analytics priority
Three tasks and their run orderJavaScript
const tasks = [  { name: "save analytics", priority: "background", posted: 1 },  { name: "show cart", priority: "user-visible", posted: 2 },  { name: "handle click", priority: "user-blocking", posted: 3 },]; console.log(orderTasks(tasks).join(" -> "));
Run order in the model
1handle click
2show cart
3save analytics
Try it yourself

The model now runs: handle click -> show cart -> save analytics. It sorts by priority first. If two tasks have the same priority, the one posted first runs first.

This is a small model made for learning. A real browser can also weigh other things, such as where a task came from.

Changing or cancelling a task

A task can become more important after you post it. Or it can stop being needed. A TaskController helps with both. It gives you a signal, an object you pass to postTask. With the controller you can:

  • give the task a starting priority;
  • change that priority later with setPriority();
  • cancel the task with abort(), if it has not started yet.

Use it when someone changes their mind, or when a search result is no longer needed.

A controller that can change the priorityPop out in the code editor (opens in a new tab)JavaScript
if (globalThis.scheduler?.postTask && "TaskController" in globalThis) {
  const controller = new TaskController({ priority: "background" });
  scheduler.postTask(() => console.log("sync search"), {
    signal: controller.signal,
  });
  controller.setPriority("user-visible");
  // controller.abort() would cancel a task that has not started.
} else {
  console.log("TaskController is not supported here");
}

Line 1 checks that the browser has both postTask and TaskController. Line 2 makes a controller with background priority. Lines 3 to 5 post a task with the controller's signal and no priority option. Line 6 raises the priority to user-visible.

The comment on line 7 notes that controller.abort() would cancel the task if it has not started. In a browser without these tools, the code prints TaskController is not supported here.

One rule matters here. A priority you pass straight to postTask is fixed for that task: it can never change. To change it later, pass a TaskController signal and leave out the priority option. If you only need to cancel, an ordinary AbortController is enough. It is the standard object for cancelling work.

Cancelling is normal

Cancelling a task is not an error. A new search word, a closed panel, or a component removed from the page can make old work useless. Then you simply cancel it.

Yield without losing your place

Yielding means ending the current task on purpose, so the browser can run other waiting work. scheduler.yield() returns a promise. The await in front of it pauses your async function. When the promise resolves, the rest of the function runs in a later task. That rest is called the continuation.

Where the rest of the function runsPop out in the code editor (opens in a new tab)JavaScript
if (globalThis.scheduler?.yield && globalThis.scheduler?.postTask) {
  scheduler.postTask(() => console.log("same priority task"));
  scheduler.postTask(() => console.log("urgent task"), {
    priority: "user-blocking",
  });
  await scheduler.yield();
  console.log("yield continuation");
} else {
  console.log("scheduler.yield is not supported here");
}

Line 2 posts a task with the default priority, user-visible. Lines 3 to 5 post an urgent, user-blocking task. Line 6 yields and waits. Line 7 is the continuation. In a browser that has scheduler.yield, the order is: urgent task, yield continuation, then same priority task.

The run order in Chrome. First, urgent task, because it is user-blocking. Second, yield continuation, the rest of the function after await scheduler.yield(). Third, same priority task, even though it was posted first.run order1. urgent task2. yield continuation3. same priority taskuser-blocking, most urgentrest of the paused functionposted first, but waits
The urgent task still runs first. After that, the rest of the paused function goes ahead of same priority task, which was posted first.

Why this order? The continuation keeps the priority of the code that yielded. It also goes ahead of other tasks with that same priority. Work with a higher priority, like the urgent task, still goes first.

The fallback, setTimeout(resolve, 0), also yields. That is still much better than blocking the page. But its callback goes to the back of the line, behind other waiting tasks. So it does not get the same head start. That is why the first example checks for scheduler.yield and uses the timer only when it is missing.

Step through: where the rest of the function runs
Step 0 of 4Ready
Your turn: follow the blue line

These steps replay a small priority model made for learning. They show the order the browser documents for a yield. They do not look inside a real browser.

Running in
  1. script
Next: line 1
  { name: "same priority task", priority: "user-visible", posted: 1 },  { name: "urgent task", priority: "user-blocking", posted: 2 },  { name: "yield continuation", priority: "user-visible", posted: 3, continuation: true },]; console.log(orderTasks(queue).join(" -> "));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Why not ask isInputPending

navigator.scheduling.isInputPending() was a test feature that only some browsers tried. It answered one question: might a click or key press be waiting? The idea was that a long loop could keep working while the answer was no. Today the advice is different: don't use it in new code. Yield regularly instead.

The old input checkPop out in the code editor (opens in a new tab)JavaScript
const inputIsWaiting = navigator.scheduling?.isInputPending?.();
console.log(inputIsWaiting ? "input may be waiting" : "yield regularly anyway");

Line 1 calls the method only if it exists. The ?. (optional chaining) stops and gives undefined when the part before it is missing. Line 2 prints one of two messages. Where the method is missing, it prints yield regularly anyway.

The code runs, but a yes-or-no answer is not enough to keep a page smooth. The method can answer false even when input is waiting. And input is not the only work that needs a turn: animations and painting need turns too.

A better plan: do a small, limited chunk of work, then await the yieldToMain helper from the first example. That makes room for input whether the browser reports it or not. This plan is simpler to read. It doesn't ask the same question again and again in a tight loop. And it gives the browser chances to paint.

Keep a steady rhythm:

  • make each chunk small enough for slow phones;
  • yield at a clear stopping point;
  • make sure the screen is correct before the next chunk starts.

A steady rhythm is easier to test than guessing when someone will click.

Find Long Animation Frames

A frame is one update of what you see on the screen. A Long Animation Frame (LoAF for short) is a screen update that took more than 50 ms. It can include JavaScript, the browser's own drawing work, and more than one task.

LoAF reports help because a page can feel slow even when no single function is to blame. The slow frame may be several pieces of work added together.

Watch for long frames, where the browser supports itPop out in the code editor (opens in a new tab)JavaScript
const supportsLoaf = PerformanceObserver.supportedEntryTypes?.includes(
  "long-animation-frame",
);

if (!supportsLoaf) {
  console.log("not supported here");
} else {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      console.log("LoAF", entry.duration > 50, entry.scripts.length);
    }
  });
  observer.observe({ type: "long-animation-frame", buffered: true });
}

Lines 1 to 3 check whether the browser knows the long-animation-frame entry type. Line 6 prints not supported here if it doesn't.

Line 8 makes a PerformanceObserver. It is an object that tells you about speed records (called entries) as the browser makes them. For each long frame, line 10 prints LoAF, whether the frame took more than 50 ms, and how many scripts added to it. Line 13 starts watching. buffered: true also gives you the entries recorded before it started.

Each entry has a scripts list. Start with the longest frames that happen again and again. Then look at the event handler or drawing work that starts them. Today, only Chromium browsers (like Chrome and Edge) support LoAF. So treat it as extra evidence. In other browsers, keep using the browser's profiler, the tool that records where time goes.

Measure before changing priority

A priority cannot make slow work cheap. First, measure a slow interaction. Next, remove work you don't need. Then split what is left into chunks at clear stopping points.

A search that keeps responding

Picture a search box over many products, all checked inside the page. This plan keeps the page quick to react:

  1. Show the typed text right away.
  2. Check a small group of products, then yield.
  3. Check the next group, and so on.
  4. When the results look right, save analytics or fill a cache. Give that work background priority where the browser supports it.
Go through a list in small groupsPop out in the code editor (opens in a new tab)JavaScript
async function filterProducts(products, matches) {
  for (let index = 0; index < products.length; index += 50) {
    matches.push(...products.slice(index, index + 50));
    await (globalThis.scheduler?.yield
      ? scheduler.yield()
      : new Promise((resolve) => setTimeout(resolve, 0)));
  }
  console.log("filtered", matches.length);
}

filterProducts(["tea", "milk", "bread"], []);

Line 2 steps through the list 50 products at a time. Line 3 adds one group to matches. Lines 4 to 6 yield before the next group. Line 8 prints filtered 3 for this small list. In a real app, also stop old work with an abort signal when the person types a new search.

This builds on the main thread, debounce and throttle, and measuring performance. Debouncing decides when to start work. Scheduling decides how running work shares time with the rest of the page.

Place each task at a priority
  • Handle a click that opens the cart
  • Update search results after typing
  • Finish a visible product filter
  • Prepare an image preview on screen
  • Save analytics after the screen updated
  • Clean an old in-memory cache
Try it yourself
0 of 6 correct

Sort each job by how much the person is waiting for it. Read the explanation after every card.

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

Choose the scheduling tool

These tools solve different problems:

  • Idle callbacks find free time for optional work.
  • postTask adds a new task and says how urgent it is.
  • scheduler.yield() splits work you already started.
  • LoAF measures a slow screen update after it happened.

Each row of the table is one tool. Read across to see what it is for and what to watch out for.

Scheduling tools and what each one is for
ToolWhat is it for?What should you watch for?
requestIdleCallbackIt runs small work nobody is waiting for, in the browser's free time.Check timeRemaining() before each step. Add a timeout only for work that must happen at some point.
scheduler.postTaskIt adds a new task with a priority: user-blocking, user-visible, or background.Check that the browser has it. A priority you pass straight to it can never change later.
scheduler.yieldIt splits long work, so the rest runs in a later task.Await it. Where it exists, the rest of your function goes ahead of tasks with the same priority.
isInputPendingIt was an old test feature that guessed whether input was waiting.Don't use it in new code. Yield regularly instead.
Long Animation FramesThey measure screen updates that took more than 50 ms.Check that the browser knows long-animation-frame. Then look at the scripts each entry lists.

Not every browser has these tools. requestIdleCallback, scheduler.postTask, scheduler.yield, and LoAF all need a check before you use them. A fallback can keep the page quick to react, even if it can't copy every priority rule of the real tool.

Common scheduling mistakes

  • “A priority makes code fast.” A priority only changes when work runs. It doesn't make the work smaller. You still need to cut slow work.
  • “Idle means free time is guaranteed.” A busy page may have no free time. That is why work that must happen needs a timeout.
  • “Yielding runs code in parallel.” It pauses one task on the main thread and goes on later. It doesn't make a second thread.
  • “Check isInputPending before yielding.” Today's advice is to yield regularly, and not to rely on that test feature.
  • “A LoAF is one slow function.” It is one slow screen update. It can include several tasks plus the browser's drawing work.

A simple rule still helps: show feedback right away, do only the work you need, split the rest into small chunks, and measure what people feel. Don't build a big scheduling system for a small click handler until measurements show you need one.

Practice exercises

Exercise 1 · Warm-upPredict the final chunk line

What is the last line this order example prints?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const orders = ["tea", "milk", "bread", "fruit", "rice"];
for (const order of orders) console.log("chunk", order);
console.log("done");

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

    Exercise 2 · Warm-upRead the priority model

    Which task name prints first?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const tasks = [
      { name: "background", priority: 2, posted: 1 },
      { name: "click", priority: 0, posted: 2 },
    ];
    console.log(tasks.sort((a, b) => a.priority - b.priority).map((task) => task.name).join(","));

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

      Exercise 3 · PracticeRespect a short idle deadline

      What does this model decide when no time is left?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      console.log(0 > 4 ? "run one small background step" : "wait for another idle period");

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

        Exercise 4 · PracticeName the function that yields

        Which function do you await to yield, so that the rest of your function keeps its priority? (It works only in browsers that have it.)

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

          Exercise 5 · ChallengeApply this to product search

          A product list filter takes too long while someone types. What should the app do?

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

            Exercise 6 · ChallengeName the long-frame entry type

            Which entry type reports a Long Animation Frame?

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

              Check your understanding

              For each question, think about what the person needs to see next. Don't pick a tool just because it looks interesting.

              Task scheduling quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does this priority model print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const tasks = [{ name: "background", priority: 2 }, { name: "click", priority: 0 }];
                console.log(tasks.sort((a, b) => a.priority - b.priority).map((task) => task.name).join(","));

                Choose an answer to see the explanation.

              2. Question 2 of 7When is it fine to treat work as optional idle work?

                Choose an answer to see the explanation.

              3. Question 3 of 7What does a timeout option on requestIdleCallback mean?

                Choose an answer to see the explanation.

              4. Question 4 of 7How do TaskController and postTask work together?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why use scheduler.yield() when the browser has it?

                Choose an answer to see the explanation.

              6. Question 6 of 7What is today's advice for isInputPending?

                Choose an answer to see the explanation.

              7. Question 7 of 7What does a LoAF observer report?

                Choose an answer to see the explanation.

              Key takeaways

              • Use user-blocking for work someone is waiting for right now, like the answer to a click.
              • Use idle time only for small background work that is allowed to wait.
              • Use a TaskController signal when a posted task may need a new priority or may be cancelled.
              • Yield regularly to split long work on the main thread. With scheduler.yield(), the rest of your function goes ahead of tasks with the same priority.
              • Don't use isInputPending in new code. A page needs turns for more than input.
              • Use Long Animation Frames to measure slow screen updates, in browsers that support them.

              Remember the one-liner.
              Run urgent work first, split long work into small chunks, and measure the screen updates people wait for.

              Coming next: The Node.js event loop. There you will see how Node.js runs its work in fixed steps, called phases. You will also meet process.nextTick and setImmediate.

              CompleteFrontend Clear concepts. Working examples.