cf.completefrontendCode editorOpen lab
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 priorityClassify urgent, visible, and background work without treating every task as equally important.
  • 02
    Break up long workUse yielding and small chunks so a page can handle input and paint between pieces of work.
  • 03
    Measure 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.

Five small chunksPop 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 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.

Real-life analogyA ticket counter queue

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.

Ask how much idle time remainsPop 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 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.

A tiny idle decision modelPop 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. 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.

Post urgent work ahead of 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 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.

Step through priority selection
Step 0 of 4Ready
Your turn: follow the blue line

Replay an instrumented lesson model. It teaches priority ordering; it is not a browser event-loop debugger.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  { 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(" -> "));
CallStoreChangeResultRun = next line. 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 one task priority
Priority ordering modelJavaScript
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(" -> "));
Modeled run order
1handle click
2show cart
3save analytics
Try it yourself

The model now runs: handle click -> show cart -> save analytics. It orders priority first, then posting order.

This is a teaching model. Browser scheduling can also consider task sources and other platform work.

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.

Use a controller for a mutable taskPop 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 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.

Keep cancellation ordinary

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.

Compare a yield continuationPop 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");
}

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.

Step through a yield continuation
Step 0 of 4Ready
Your turn: follow the blue line

Replay an instrumented priority model. It explains the documented yield ordering without claiming to inspect a browser queue.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  { 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(" -> "));
CallStoreChangeResultRun = next line. 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 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.

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 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.

Observe supported Long Animation FramesPop 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 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.

Measure before changing priority

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.

Chunk a visible list carefullyPop 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 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.

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 UI updated
  • Clean an old in-memory cache
Try it yourself
0 of 6 correct

Sort the work by what the reader is waiting for. 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 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.

Scheduling tools at a glance
ToolUse it forImportant limit
requestIdleCallbackBest-effort background work during an idle periodCheck timeRemaining(); use timeout only for work that must eventually run.
scheduler.postTaskA new task with user-blocking, user-visible, or background priorityFeature-detect it; a fixed priority cannot later be changed.
scheduler.yieldBreak long work into future tasksAwait it; supported browsers keep a boosted continuation at the same priority.
isInputPendingAn old experimental hint about pending inputDo not use it for new scheduling; yield regularly instead.
Long Animation FramesMeasure slow rendering updates over 50 msFeature-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

Exercise 1 · Warm-upPredict the final chunk line

What is the final line printed by this chunked-order example?

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 single task 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 should this model do with no time 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 yielding API

        Which API gives a prioritized continuation when it is available?

        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 LoAF entry type

            Which entry type reports a Long Animation Frame?

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

              Check your understanding

              Choose based on the reader's next visible need, not on how interesting the API looks.

              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 should idle work be treated as optional?

                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 prefer scheduler.yield when it exists?

                Choose an answer to see the explanation.

              6. Question 6 of 7What is the current 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

              • 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.

              CompleteFrontend Clear concepts. Working examples.