What you'll learn: By the end of this lesson you will be able to split a long task into slices that fit a time budget, yield with
scheduler.yield()and a safe fallback, assign priorities withscheduler.postTask(), use idle time for low-value work, explain why a continuation should run before other tasks of the same priority, and predict how each choice changes the time a key press waits.
A hospital emergency room has one doctor on duty. A nurse triages the queue: chest pain first, a sprained ankle later, a routine check-up last. Now suppose the doctor starts a two-hour procedure on the check-up and never pauses. Chest pain arrives, and the nurse can only watch the clock. Triage is not enough. The doctor must also stop at safe points, take the urgent case, and go back.
A browser's main thread is that doctor. The event loop lesson showed why a long task blocks input and how to break one up with a simple yield. This lesson goes one level up. With many pieces of work of different importance (render the hero, build the list, send analytics, build a search index), the question is no longer "how do I yield?" but "in what order should things run, and who decides?". That is scheduling.
The running example is a product page: a hero block, a list of 500 product cards, a search box that filters as you type, and a background job that builds a search index. Every number here is an illustrative input to a model, not a measurement.
The core idea
Hold on to three statements.
- A task cannot be interrupted, so you create the interruption points yourself. Between two slices the browser can run input handlers and render a frame. Without a yield there is nothing between them.
- Importance should decide order. Priorities let urgent work jump ahead of deferrable work at every interruption point.
- Where a continuation goes matters. After you yield, your remaining work is queued again. At the back of the line, other work interleaves and your job finishes later. At the front of its priority, your job finishes sooner and others wait. It is a policy, and APIs differ.
From intuition to internals
Long tasks and the 50 ms budget
web.dev defines a long task as any task longer than 50 ms, and suggests 50 ms as a common deadline for keeping tasks from becoming long. A key press that arrives during a 240 ms task waits for the rest of it. Slicing does not make the total work smaller. It makes the longest wait smaller.
Yielding with scheduler.yield()
scheduler.yield() returns a promise that resolves in a later task. MDN says the continuation goes into a boosted queue, often described as the front of its priority level's queue, while a scheduler.postTask() task of the same priority goes to the end. The default priority is "user-visible", but a yield() inside a postTask() callback inherits that task's priority and abort signal. web.dev puts it plainly: the continuation runs before other similar tasks are started.
Support is partial. web.dev's support list, read on 2026-11-08, shows Chrome and Edge 129, Firefox 142, and no Safari. MDN marks it as not Baseline. So feature-detect and keep a fallback:
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise((resolve) => setTimeout(resolve, 0));
}
The fallback still yields, but web.dev notes that a setTimeout continuation goes to the end of the queue, and tasks already waiting run before it. That difference matters when you rely on ordering.
Priorities with scheduler.postTask()
scheduler.postTask(callback, options) queues a task with one of three priorities, from highest to lowest: "user-blocking" (work that stops users interacting, such as rendering the page to a usable state or responding to input), "user-visible" (the default, visible but not blocking) and "background" (not time-critical, such as log processing). MDN says scheduled tasks run "in priority order, followed by the order that they were added". The draft specification (a WICG Community Group draft, not a W3C Standard) is firmer: tasks of one scheduler run in strict priority order, the next task is the oldest runnable one of the highest priority, and continuations rank above tasks of the same priority.
Options: delay (a minimum in ms: the task may start later, never earlier), signal (an AbortSignal to cancel, or a TaskSignal from a TaskController to change priority later) and priority (fixed for the task's life; a priority on the signal is then ignored). The promise rejects with the signal's abort reason if the task is aborted.
Figure 1. Where scheduler tasks sit in the event loop
How to read this: the scheduler orders only its own three queues. How the event loop weighs scheduler tasks against input and rendering is left to the browser: the draft calls it implementation-defined. Do not assume your background task always loses to a key press, or always wins.
Idle time with requestIdleCallback()
requestIdleCallback() queues a function for a browser idle period, so low-priority work does not disturb animation and input. MDN says callbacks are generally called first-in-first-out, and a timeout is "strongly recommended for required work", since otherwise multiple seconds may pass before it runs. MDN lists it as limited availability (not Baseline). Idle time suits work you can lose or redo, such as prefetching or warming a cache, never work the user is waiting for.
Choosing
Figure 2. Which scheduling tool for which work
waiting for it?"} -->|"yes"| Q2{"Longer than
about 50 ms?"} Q2 -->|"no"| A["Run it now"] Q2 -->|"yes"| B["Slice it and yield
between slices"] Q1 -->|"no, it is visible later"| C["postTask: user-visible"] Q1 -->|"no, nobody notices"| Q3{"Can it be lost
or redone?"} Q3 -->|"yes"| D["requestIdleCallback
(with a timeout if required)"] Q3 -->|"no"| E["postTask: background"] Q2 -->|"heavy, no DOM"| F["Consider a Web Worker
(a later lesson)"]
How to read this: start with the user's wait. Slicing and priorities both keep the main thread responsive; workers move the cost away, with their own price.
Three illustrations
1. A minimal example: priority order
Three tasks are queued at time 0, in the worst order: analytics (background, 20 ms), product-list (user-visible, 30 ms), hero-image (user-blocking, 10 ms). A priority scheduler runs them in rank order, whatever the queueing order. The lab's model prints:
0 -> 10 hero-image#0
10 -> 40 product-list#0
40 -> 60 analytics#0
Nothing preempts: a priority only decides who goes next.
2. A realistic example: the search box
On the product page, the search filter takes 240 ms of work (illustrative) and is a single task. Three key presses arrive at 30, 90 and 150 ms, each with a 6 ms handler. The model shows the unsliced result and two sliced ones:
| Plan | Long tasks | Worst input delay | Worst key-to-paint | Search finishes |
|---|---|---|---|---|
| One 240 ms task | 1 | 210 ms | 220 ms | 240 ms |
| Yield every 40 ms | 0 | 30 ms | 40 ms | 270 ms |
| Yield every 16 ms | 0 | 14 ms | 24 ms | 270 ms |
Slicing made the first key press wait 10 ms instead of 210 ms, and the search finished 30 ms later (three handlers and three renders ran in between). That is the trade: a slightly later finish for a far better worst wait. web.dev warns that yielding too often can cost more in yielding and resuming than the work itself; the model charges nothing per slice (15 slices at 16 ms, 6 at 40 ms), so it cannot show that cost.
3. A failure case: the continuation at the back, and starvation
Two tasks of the same priority, A and B, each 90 ms in 30 ms slices:
continuation at the front order: A#0 A#1 A#2 B#0 B#1 B#2 A finishes at 90, B at 180
continuation at the back order: A#0 B#0 A#1 B#1 A#2 B#2 A finishes at 150, B at 180
Both finish all work at 180 ms; A is done at 90 ms or at 150 ms. With a back-of-the-line fallback, your job is interleaved with everything else of its rank.
A second failure hides in strict priority. If user-blocking work keeps arriving, background work can wait until it stops. That follows from strict ordering, so background work needs a way out (a deadline or a promotion).
Build it: a scheduler you can step through
Goal. See the numbers in the table above come out of a small, deterministic model, and test the two helpers (makeYieldToMain and runInSlices) with a fake clock.
Setup. The lab lives in the course's example workspace: examples/frontend/labs/task-scheduler-model. It needs Node.js (captured on Node 26.12.1, which runs the TypeScript directly). The shared toolchain is installed once for the workspace with pnpm.
cd examples/frontend
pnpm install
cd labs/task-scheduler-model
pnpm test # 31 tests
node src/demo.ts # prints the scenarios below
Steps.
- Read
simulate()insrc/model.ts. The loop renders if due, then runs an input handler, then the best scheduler task, then idles until the next arrival. - In the demo's "search-unsliced", the key press at 30 ms starts its handler at 240 ms: an input delay of 210 ms, the whole remainder of the long task.
- In "search-sliced-40" the same press waits 10 ms. The press at 150 ms waits 30 ms, because the slice running then (140 to 180) cannot be interrupted.
- Compare the "front" and "back" outputs: the orders differ, the finishing time of the last task does not.
- In
test/chunk.test.ts,runInSlicesyields after a slice exceeds its budget and checks a stale flag after each yield. It can overrun the budget by one item, because the check follows the item.
Expected output (the first scenario), captured from a real run (Node 26.12.1, 2026-11-08). The sliced runs and the rest are in examples/frontend/golden/task-scheduler-model.txt.
=== search-unsliced: one 240 ms task, three key presses while it runs
0 -> 240 search#0 LONG TASK
240 -> 246 input key-1
246 -> 250 render
250 -> 256 input key-2
256 -> 260 render
260 -> 266 input key-3
272 -> 276 render
key-1: at 30, handler 240-246, input delay 210 ms, painted at 250, latency 220 ms
key-2: at 90, handler 250-256, input delay 160 ms, painted at 260, latency 170 ms
key-3: at 150, handler 260-266, input delay 110 ms, painted at 276, latency 126 ms
summary: {"maxInputDelay":210,"maxLatency":220,"longTasks":1,"frames":3}
This is a model, not a browser. Time is virtual. The model decides that input handlers go before scheduler tasks and that a render follows a handler at the next frame boundary. Those are its own choices: the scheduling draft leaves the choice between scheduler tasks and other event-loop task queues implementation-defined. The lab says nothing about any real engine's behaviour, and only ran on Node 26.12.1.
Break it. In src/model.ts, in pickScript, remove the block that ranks continuations above same-priority tasks. The test "front mode: a continuation runs before other tasks of the same priority" fails and the front-mode order becomes the interleaved one. Revert it.
Stretch. Add TaskController.setPriority, a render policy, or an idle priority to the model.
Figure 3. What the sliced search does to a key press
How to read this: the key press is not served mid-slice. It is served at the slice boundary, so slice length is the ceiling on input delay, plus any render or earlier handler queued ahead of it.
In practice: a product page that is busy on load
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A product page hydrates a list of 500 cards, starts a search-index build for instant filtering, loads an analytics bundle, and renders a hero block. On low-end phones the first tap, on the size selector, is ignored for about a second after load. Field data shows poor responsiveness only for visits where the user taps during the first seconds. Four engineers own the page.
Starting architecture
Figure 4. Everything starts at once, at one priority
How to read this: nothing ranks the jobs. The tap competes with whatever happens to be running.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
Slice the index with scheduler.yield() |
Long task gone | Async loop, a fallback | Slower index completion |
Post jobs with priorities (user-blocking hydrate of the visible cards, user-visible rest, background index and analytics) |
Order follows value | Per-job discipline | Strict priority can delay background work |
| Idle callback for index and analytics | Work happens when idle | Needs a timeout; not in every browser |
Work may start late |
The decision
| Field | Decision record |
|---|---|
| Context | Several unranked jobs on load; a tap waits behind a long index build. |
| Decision | Slice the index at a 16 ms budget; post hydration of visible cards as user-blocking, the rest of the list as user-visible, the index and analytics as background with a fallback to a timer where postTask is missing. |
| Consequences, good | A tap waits at most one slice; order follows importance. |
| Consequences, bad | More code paths (supported and fallback); the index finishes later. |
| Revisit when | Field INP for taps in the first seconds is still not good, or background work does not finish before the user leaves the page. |
Rollout and verification
- Record a throttled load in the browser's performance tools and read the main-thread timeline first.
- Ship behind a flag and compare field INP and index completion (a custom timing) for flagged and unflagged visits.
- Test the fallback in a browser without
scheduler.
The 2 a.m. failure
A new promotion banner posts its animation work as user-blocking every frame. On a busy campaign night, the background index never completes for sessions that stay on the page. Search falls back to a slow path and support tickets arrive.
First three checks: (1) list who posts user-blocking work and how often; (2) check the timeout or deadline on the index (there was none); (3) confirm in a throttled profile that background tasks do not start while the banner animates. Mitigation: lower the banner to user-visible and cap its work per frame. Follow-up: give background jobs an explicit deadline that promotes them, and add a review rule that user-blocking is reserved for work that blocks the user.
Trade-offs
| Choice | Gains | Costs | Use when |
|---|---|---|---|
Slice with yield() (continuation first) |
Short tasks; job finishes soon | Async code; partial support | Most chunked work that needs the DOM |
| Slice with a timer fallback | Works everywhere | Continuation at the back of the queue | Browsers without scheduler.yield |
postTask priorities |
Order follows value | Strict priority can starve low ranks; partial support | A page with several unlike jobs |
requestIdleCallback |
Stays out of the way | Unpredictable start; limited availability | Work you can lose or redo |
Architect's notes
- Define the priorities once. Decide what counts as
user-blockingfor the whole team, and keep it rare. - Keep scheduling behind one module with feature detection and a fallback, testable with an injected clock as in the lab.
- Give background work a deadline. Strict priority makes starvation a design question.
- Slice by time, not item count, and compare field INP, not only lab numbers.
Common mistakes
- Treating a priority as preemption. A running task is never interrupted; priority only picks the next one.
- Assuming the fallback behaves like
scheduler.yield(). It queues at the back; ordering and finishing time change. - Marking everything
user-blocking. The label stops meaning anything. - Using idle callbacks for required work without a timeout. MDN warns it may take seconds to run.
Key takeaways
- Splitting a long task shrinks the longest wait, not the total work.
scheduler.yield()continues your task ahead of other tasks of the same priority; asetTimeoutfallback continues at the back.postTaskpriorities areuser-blocking,user-visible(default) andbackground; strict order, oldest first within a rank.- Priority does not preempt a running task, and the draft leaves the choice between scheduler tasks and input or rendering to the browser.
requestIdleCallbackis for work you can lose or redo, and required work needs atimeout.- Feature-detect all of these; none was Baseline at the time of writing.
Practice exercises
Exercise 1, Starter (30 min): In the lab's demo.ts, move a key press to land exactly at a slice boundary. Predict the input delay, then run it.
Exercise 2, Intermediate (2 hours): Add a user-blocking task arriving at 100 ms to the abort scenario. Predict the new order, then check.
Exercise 3, Advanced (half day): Write makeScheduler(env) that uses postTask when present and a timer queue otherwise, keeping priority order in the fallback. Test both branches with fakes, and list what you could not reproduce without a browser.
Related reading
In this course:
- The Event Loop and the Main Thread: tasks, microtasks, rendering, long tasks and the first yielding techniques.
- Loading Performance and Core Web Vitals: how INP and TBT are measured and judged.
Planned in this course, in plain text: the lessons on Web Workers and offloading, and on Interaction to Next Paint and responsiveness, which uses the slicing and priorities from this lesson.
Official documentation
- WICG: Prioritized Task Scheduling: priorities, strict order, continuations,
yield()and the interleaving note (a Community Group draft). - MDN: Scheduler.postTask(): priority, delay, signal and abort.
- MDN: Scheduler.yield(): the boosted continuation and inherited priority.
- MDN: Prioritized Task Scheduling API: the three priorities and
TaskController. - MDN: Window.requestIdleCallback(): idle periods,
timeout, availability. - web.dev: Optimize long tasks: the 50 ms long task,
scheduler.yield()and its support list.