Skip to content

    Frontend System Design

    Scheduling Work: Yielding, Idle Time and Task Priorities

    Learn how to split long tasks, yield with scheduler.yield, set priorities with postTask, use idle time, and test a scheduler model under a fake clock.

    By Published 12 min read

    • frontend-system-design
    • scheduling
    • long-tasks
    • scheduler-api
    Share

    Standard lessonadvancedTypeScript labFrontend System Design

    Before this: The Event Loop and the Main Thread

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

    1. 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.
    2. Importance should decide order. Priorities let urgent work jump ahead of deferrable work at every interruption point.
    3. 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

    Input, rendering and prioritised scheduler tasks all compete for the main threadInput events to Event loop; user-blocking to Event loop (first); user-visible to Event loop (then); background to Event loop (last); Rendering to Event loop; Event loop to Main thread (run one task)SCHEDULER QUEUESInput events: clicks, keysInput eventsclicks, keysuser-blocking: scheduler queueuser-blockingscheduler queueuser-visible: scheduler queueuser-visiblescheduler queuebackground: scheduler queuebackgroundscheduler queueEvent loop: picks the next taskEvent looppicks the nexttaskMain thread: runs it to completionMain threadruns it tocompletionRendering: update the renderingRenderingupdate therenderingfirstthenlastrun one task

    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

    flowchart TD accTitle: Choosing how to schedule a piece of work accDescr: A decision tree. If the user is waiting, run it now and slice it with yield. If it matters but can be slower, post it with user-visible or user-blocking priority. If it is deferrable, post it as background or use idle time. If it is heavy and needs no DOM, consider a worker. Q1{"Is the user
    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.

    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.

    1. Read simulate() in src/model.ts. The loop renders if due, then runs an input handler, then the best scheduler task, then idles until the next arrival.
    2. 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.
    3. 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.
    4. Compare the "front" and "back" outputs: the orders differ, the finishing time of the last task does not.
    5. In test/chunk.test.ts, runInSlices yields 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

    sequenceDiagram accTitle: A key press during a sliced search accDescr: The search runs a 40 ms slice and yields. A key press that arrived during the slice runs its handler at the next boundary, a frame is rendered, and the search continues with its next slice. participant U as User participant S as Scheduler participant M as Main thread S->>M: search slice 0 to 40 U->>M: key press at 30 (waits) M-->>S: slice ends, yields M->>M: key handler 40 to 46 M->>M: render 46 to 50 S->>M: search continues, slice 50 to 90

    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

    NOTE

    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

    Starting architecture: four jobs started on load, all as plain tasksPage load to Hydrate list; Page load to Build search index; Page load to Analytics init; User taps size to Build search index (queued)MAIN THREADPage loadPage loadHydrate list: 500 cardsHydrate list500 cardsBuild search index: long taskBuild searchindexlong taskAnalytics initAnalytics initUser taps size: waits behind themUser taps sizewaits behind themqueued

    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

    1. Record a throttled load in the browser's performance tools and read the main-thread timeline first.
    2. Ship behind a flag and compare field INP and index completion (a custom timing) for flagged and unflagged visits.
    3. 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-blocking for 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; a setTimeout fallback continues at the back.
    • postTask priorities are user-blocking, user-visible (default) and background; 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.
    • requestIdleCallback is for work you can lose or redo, and required work needs a timeout.
    • 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.

    In this course:

    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

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments