Scheduling work
Learn to schedule JavaScript work with idle periods, task priorities, yielding, input responsiveness, and Long Animation Frames.
- 01Choose a prioritySort work into urgent, visible, and background, instead of treating every task as equally important.
- 02Break up long workSplit long work into small chunks and yield between them, so the page can handle clicks and paint.
- 03Measure 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.
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.
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.
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.
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 istruewhen 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 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.
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.
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.
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.
These steps replay a small model made for learning. It shows how priorities order tasks. It does not look inside a real browser.
script
{ 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(" -> "));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(" -> "));handle clickshow cartsave analyticsThe 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.
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.
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 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.
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.
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.
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.
script
{ 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(" -> "));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.
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.
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.
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:
- Show the typed text right away.
- Check a small group of products, then yield.
- Check the next group, and so on.
- When the results look right, save analytics or fill a cache. Give that work background priority where the browser supports it.
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.
- 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
Sort each job by how much the person is waiting for it. Read the explanation after every card.
Choose the scheduling tool
These tools solve different problems:
- Idle callbacks find free time for optional work.
postTaskadds 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.
| Tool | What is it for? | What should you watch for? |
|---|---|---|
requestIdleCallback | It 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.postTask | It 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.yield | It 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. |
isInputPending | It was an old test feature that guessed whether input was waiting. | Don't use it in new code. Yield regularly instead. |
| Long Animation Frames | They 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
What is the last line this order example prints?
const orders = ["tea", "milk", "bread", "fruit", "rice"];
for (const order of orders) console.log("chunk", order);
console.log("done");The last line is done. It prints after every order line.
Which task name prints first?
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(","));It prints click,background, so click is first.
What does this model decide when no time is left?
console.log(0 > 4 ? "run one small background step" : "wait for another idle period");The model waits for another idle period. A real callback should not start a big job when little time is left.
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.)
Use await scheduler.yield() when the browser has it, with a timer fallback for other browsers.
A product list filter takes too long while someone types. What should the app do?
Split the filter into chunks and yield between them. Cancel old searches when a newer one replaces them.
Which entry type reports a Long Animation Frame?
Watch for long-animation-frame, after checking that the browser supports it.
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.
Question 1 of 7What does this priority model print?
Read the code, then predictconst 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.
Question 2 of 7When is it fine to treat work as optional idle work?
Choose an answer to see the explanation.
Question 3 of 7What does a
timeoutoption onrequestIdleCallbackmean?Choose an answer to see the explanation.
Question 4 of 7How do
TaskControllerandpostTaskwork together?Choose an answer to see the explanation.
Question 5 of 7Why use
scheduler.yield()when the browser has it?Choose an answer to see the explanation.
Question 6 of 7What is today's advice for
isInputPending?Choose an answer to see the explanation.
Question 7 of 7What does a LoAF observer report?
Choose an answer to see the explanation.
Key takeaways
- Use
user-blockingfor 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
TaskControllersignal 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
isInputPendingin 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.