Scheduling work
Learn to schedule JavaScript work with idle periods, task priorities, yielding, input responsiveness, and Long Animation Frames.
- 01Choose a priorityClassify urgent, visible, and background work without treating every task as equally important.
- 02Break up long workUse yielding and small chunks so a page can handle input and paint between pieces of work.
- 03Measure responsivenessUse Long Animation Frames when supported and understand the limits of scheduling APIs.
Choose the next useful work
A page has one main thread for most JavaScript, input handlers, and rendering work. When one piece of JavaScript keeps that thread busy, a click, a paint, or a small visible update must wait. Task scheduling means deciding what should run next and where your work can pause.
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 creates yieldToMain. It uses scheduler.yield() when the browser has it and otherwise uses a zero-delay timer. Line 8 prints one order. Line 9 awaits a future task before moving to the next order. The output is chunk tea through chunk rice, then done.
This is cooperative work: the loop chooses safe stopping points. A yield does not make the work faster or move it to another CPU. It gives the browser a chance to handle work that should happen before the next chunk. Use a worker when work can genuinely leave the main thread.
Pick chunk boundaries that preserve a meaningful intermediate state. A cart can show its current item count before it calculates recommendations. A search can show the latest typed text before it filters every product. Do not split a small calculation merely because yielding exists; the extra tasks have overhead too.
A ticket counter has urgent requests, ordinary requests, and paperwork that can wait until nobody is waiting. Letting one person step aside briefly lets a more urgent request move first.
- In real life: A person at the counter
- In JavaScript: A task uses 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
Where the analogy stops: A browser has several task sources and rendering rules, so this is a priority model, not a complete queue diagram.
Idle periods and requestIdleCallback
An idle period is time when the browser believes it can run low-priority work without delaying input or rendering. requestIdleCallback asks for one of those opportunities. It is for optional maintenance such as preparing a cache, not for a result a click must show immediately.
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 support before calling the API. Line 4 receives an IdleDeadline. Line 5 asks whether more than zero milliseconds remain. Line 6 reports whether the browser ran the callback because its timeout elapsed. In an unsupported browser the exact output is idle callback is not supported here.
A busy page can have no useful idle time. Required work should provide a timeout so it eventually becomes a task, but that is a trade-off: a timed-out callback can add pressure at a bad moment. Keep each idle step short and schedule another callback for the rest.
console.log(0 > 4 ? "run one small background step" : "wait for another idle period");This one-line model prints wait for another idle period. A real deadline is not a promise of a fixed duration. It is a last-minute signal, so do not start a large loop just because some time remains.
scheduler.postTask priorities
Prioritized task scheduling lets a supported browser place posted work into three broad priorities. user-blocking is for work a person is waiting on, user-visible is normal visible work and the default, and background is work that should wait behind both.
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 2 posts analytics as background work. Line 5 posts a click response as user-blocking work. In Chrome, the click task runs before the earlier background task. On browsers without this API the snippet prints postTask is not supported here. That feature check is part of production code, not a lesson-only detail.
Priority is a hint about urgency, not a license to mark everything urgent. If every task claims user-blocking, background work never gets a fair chance. Start with the question: would the reader notice a delay right now? That often separates visible work from maintenance work.
Replay an instrumented lesson model. It teaches priority ordering; it is not a browser event-loop debugger.
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 orders priority first, then posting order.
Changing or cancelling a task
A task may become more important after it is posted. TaskController creates a signal that can give a task an initial priority, later change that priority, or abort the task before it starts. Use it when a user changes their mind or 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 2 creates a controller with background priority. Lines 3 through 5 post using its signal, without a fixed priority option. Line 6 raises it to user-visible. The comment shows that abort() can cancel a task that has not begun. Unsupported browsers print TaskController is not supported here.
There is one important rule. A priority passed directly to postTask is fixed for that task. When you want later changes, pass a TaskController signal and leave the direct priority option out. Use an ordinary AbortController when cancellation is all you need.
Cancellation is not an error in product behavior. A new search term, a closed panel, or an unmounted component can make old scheduled work irrelevant.
Yield without losing your place
Yielding is deliberately ending the current task so the browser can run other pending work. scheduler.yield() returns a promise. The await is what pauses the async function; when the promise resolves, its continuation resumes in a later task.
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");
}Lines 2 through 4 post ordinary and urgent tasks. Line 6 awaits a yield. In a supporting browser the order is urgent task, yield continuation, then same priority task. The continuation keeps its priority and is boosted ahead of other same-priority posted tasks.
A setTimeout(resolve, 0) fallback also yields, which is often better than blocking a page. Its later callback goes to the back of its task queue, though, so it does not have the same continuation behavior. That is why the first example uses feature detection instead of assuming every browser has the Scheduler API.
Replay an instrumented priority model. It explains the documented yield ordering without claiming to inspect a browser queue.
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 poll isInputPending
navigator.scheduling.isInputPending() was an experimental way to ask whether input might be waiting. It was meant to let a long loop keep working when no input was present. The current guidance is different: do not use it for new scheduling code. Yield regularly instead.
const inputIsWaiting = navigator.scheduling?.isInputPending?.();
console.log(inputIsWaiting ? "input may be waiting" : "yield regularly anyway");Line 1 safely calls the optional method if it exists. Line 2 prints one of two messages. The code is runnable, but it demonstrates why a boolean hint is not enough to plan a responsive page. The API can return false despite input, and input is not the only important work: animation and rendering need turns too.
A better pattern is to process a bounded chunk, then await the feature-detected yield helper. You make time for input whether the browser reported it or not. This is simpler to review, avoids a tight polling loop, and gives paint opportunities a chance too.
This advice is intentionally boring: make a chunk small enough to be kind on slower devices, yield at a known boundary, and keep the visible state correct before starting the next chunk. A stable rhythm is easier to test than trying to guess exactly when a person will click.
Find Long Animation Frames
A Long Animation Frame, or LoAF, is a rendering update delayed beyond 50 ms. It can include JavaScript, rendering work, and more than one task. LoAF entries are useful because a page can feel slow even when no single visible function explains the whole delayed frame.
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 through 3 feature-detect the long-animation-frame entry type. Line 6 prints not supported here where the API is absent. Lines 8 through 11 observe entries and print whether the frame exceeded 50 ms plus the number of contributing scripts.
Each entry can expose its scripts list. Start with the biggest recurring frames, then inspect the event handler or rendering work that begins the chain. LoAF is currently a Chromium feature, so treat it as progressive performance evidence and keep a normal profiler workflow for other browsers.
A priority label cannot make expensive work cheap. Measure a slow interaction, reduce unnecessary work, then chunk the remaining work at clear boundaries.
A responsive work pattern
Imagine a local product search with many products. Update the visible input immediately. Filter a small group of products, yield, then filter the next group. Save analytics or warm a cache only after the visible result is in a good state, and give that maintenance work background priority when supported.
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 creates chunks of at most 50 products. Line 3 adds one chunk. Lines 4 through 6 yield before the next chunk. Line 8 prints filtered 3 for this small example. In a real app, also stop obsolete work with an abort signal when the reader enters a new search term.
This complements the main thread, debounce and throttle, and measuring performance. Debouncing decides when to start; scheduling decides how ongoing work shares time with 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 UI updated
- Clean an old in-memory cache
Sort the work by what the reader is waiting for. Read the explanation after every card.
Choose the scheduling tool
These tools solve related but different problems. Idle callbacks find optional opportunities. Posted tasks state the urgency of a separate task. Yield splits work you already started. LoAF observes a slow result after the fact. Keeping these roles separate prevents accidental complexity.
| Tool | Use it for | Important limit |
|---|---|---|
requestIdleCallback | Best-effort background work during an idle period | Check timeRemaining(); use timeout only for work that must eventually run. |
scheduler.postTask | A new task with user-blocking, user-visible, or background priority | Feature-detect it; a fixed priority cannot later be changed. |
scheduler.yield | Break long work into future tasks | Await it; supported browsers keep a boosted continuation at the same priority. |
isInputPending | An old experimental hint about pending input | Do not use it for new scheduling; yield regularly instead. |
| Long Animation Frames | Measure slow rendering updates over 50 ms | Feature-detect long-animation-frame; inspect the reported scripts. |
Browser support is not uniform. requestIdleCallback, scheduler.postTask, scheduler.yield, and LoAF all need feature checks for broad deployments. A fallback can preserve responsiveness without reproducing every priority promise of the native API.
Common scheduling mistakes
- “A priority makes code fast.” It only changes when eligible work is considered; reduce expensive work too.
- “Idle means guaranteed spare time.” A busy page may have no idle time, which is why required work needs a timeout plan.
- “Yielding runs code in parallel.” It pauses one main-thread task and resumes later; it does not create another thread.
- “Check isInputPending before yielding.” Current guidance is to yield regularly rather than rely on that experimental hint.
- “A LoAF is one slow function.” It is a slow rendering update and may include several tasks plus rendering.
The simplest rule is still useful: show immediate feedback, do only necessary work, break remaining work into honest chunks, and measure what users feel. Do not turn a small click handler into a scheduling framework before evidence says it needs one.
Practice exercises
What is the final line printed by this chunked-order example?
const orders = ["tea", "milk", "bread", "fruit", "rice"];
for (const order of orders) console.log("chunk", order);
console.log("done");The final line is done, after every order chunk.
Which single task 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 should this model do with no time 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 large job when little time remains.
Which API gives a prioritized continuation when it is available?
Use await scheduler.yield() when supported, with a feature-detected fallback for other browsers.
A product list filter takes too long while someone types. What should the app do?
Break the filter into chunks and yield between them. Abort obsolete searches when a newer query replaces them.
Which entry type reports a Long Animation Frame?
Observe long-animation-frame after feature detection.
Check your understanding
Choose based on the reader's next visible need, not on how interesting the API looks.
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 should idle work be treated as optional?
Choose an answer to see the explanation.
Question 3 of 7What does a
timeoutoption on requestIdleCallback mean?Choose an answer to see the explanation.
Question 4 of 7How do TaskController and postTask work together?
Choose an answer to see the explanation.
Question 5 of 7Why prefer scheduler.yield when it exists?
Choose an answer to see the explanation.
Question 6 of 7What is the current 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
- Give user-blocking work to interactions a reader is actively waiting for.
- Use idle periods only for small, best-effort background work.
- Use a TaskController signal when a posted task needs a mutable priority or cancellation.
- Yield regularly to split long main-thread work; native yield keeps a boosted continuation when supported.
- Do not use isInputPending for new scheduling code; responsiveness needs more than input polling.
- Use Long Animation Frames as measured evidence when Chromium support is available.
Remember the one-liner.
Schedule urgent work first, split long work honestly, and measure the frames people actually wait for.
Coming next: The Node.js event loop, where libuv phases, process.nextTick, and setImmediate change the scheduling story.