Scheduling Wasm Work with scheduler.postTask

This page answers one task: a WebAssembly module does work on the main thread — indexing, layout, syntax highlighting, image previews — and that work competes with clicks and keystrokes. You want to give the work priorities and yield at the right moments, so input stays fast without moving everything to a worker.

Prerequisites

  • [ ] A Wasm module whose work can be split into chunks (process N items, return, continue later).
  • [ ] A browser with scheduler.postTask (Chromium-based browsers; others need a fallback).
  • [ ] A way to measure responsiveness, such as Interaction to Next Paint (INP) from the web-vitals library.

Why main-thread Wasm hurts responsiveness

A call into WebAssembly runs to completion; the browser cannot interrupt it to handle a click. A 200 ms Wasm call makes any input during those 200 ms wait, and users perceive delays above about 100 ms as sluggish. The usual remedy is to split work into chunks and give the browser a chance to handle input between them. The question is how to schedule the chunks: setTimeout(fn, 0) is clamped and unprioritised, requestIdleCallback may wait a long time, and MessageChannel tricks have no priorities at all.

The Prioritized Task Scheduling API provides what is missing. scheduler.postTask(fn, { priority }) queues a task at one of three priorities — user-blocking, user-visible (the default) and background — and the browser runs higher priorities first while still giving input events precedence. scheduler.yield() returns a promise that resolves after the browser has had a chance to run pending higher-priority work, and continues at the same priority as the task that yielded, so a long task can pause without losing its place in line.

Task priorities and what to use them for user-blocking is for work the user is waiting on right now, such as applying an edit. user-visible is for work whose result appears soon, such as re-highlighting the visible area. background is for work nobody is waiting for, such as indexing a document for search. priority use for Wasm example user-blocking the user waits on it now apply an edit to the document model user-visible result visible soon re-highlight the viewport background nobody is waiting build the search index

Step 1 — make the Wasm work chunkable

Expose a function that does a bounded amount of work and reports whether more remains. For an indexer written in Rust:

#[wasm_bindgen]
impl Indexer {
    /// Processes up to `budget` documents; returns true when finished.
    pub fn step(&mut self, budget: u32) -> bool {
        for _ in 0..budget {
            match self.queue.pop() { Some(doc) => self.index(doc), None => return true }
        }
        self.queue.is_empty()
    }
}

Keep per-chunk time around 5–10 ms on target devices. A budget measured in items is simple; a budget measured in time (check performance.now() via an imported function every few items) adapts to slow devices.

Step 2 — schedule chunks with priorities

async function indexInBackground(indexer, signal) {
  await scheduler.postTask(async () => {
    while (!indexer.step(50)) {
      await scheduler.yield();               // let input and higher-priority tasks run
      if (signal?.aborted) return;
    }
  }, { priority: "background", signal });
}

const controller = new TaskController({ priority: "background" });
indexInBackground(indexer, controller.signal);

The whole indexing job runs at background priority, yielding after every 50 documents. Input events and user-blocking or user-visible tasks run in the gaps.

Step 3 — raise priority when the user starts waiting

When the user opens the search box, the index is suddenly needed. A TaskController can change the priority of tasks already queued under its signal:

searchInput.addEventListener("focus", () => controller.setPriority("user-visible"));

Remaining chunks now run ahead of background work elsewhere in the app. Combined with scheduler.yield(), which inherits the task’s current priority, the loop speeds up without restarting.

A Wasm job scheduled in chunks with yields The job is posted as a background task. Each chunk calls into Wasm for a bounded amount of work, then yields. Input events run during yields. When the user starts waiting, the controller raises the job's priority, and the remaining chunks run sooner until the job finishes. postTask (background) TaskController signal Wasm chunk ~8 ms indexer.step(50) scheduler. yield() input runs here setPriority on focus user-visible now job completes index ready

Step 4 — abort work that is no longer needed

When the input changes — the user types another character — work for the old input is wasted. Abort it with the controller and start a new job:

let current;
editor.on("change", (doc) => {
  current?.abort();
  current = new TaskController({ priority: "user-visible" });
  highlight(doc, current.signal).catch((e) => { if (e.name !== "AbortError") throw e; });
});

postTask rejects with an AbortError if the task is aborted before it starts; your loop checks signal.aborted between chunks to stop work in progress. The Wasm side may need a reset() to discard partial state.

Step 5 — fall back where the API is missing

Browsers without scheduler.postTask need a fallback. A minimal one maps priorities onto what exists and yields with a MessageChannel or setTimeout:

const yieldToMain = globalThis.scheduler?.yield
  ? () => scheduler.yield()
  : () => new Promise((r) => setTimeout(r, 0));

const postTask = globalThis.scheduler?.postTask
  ? (fn, opts) => scheduler.postTask(fn, opts)
  : (fn) => new Promise((r) => setTimeout(() => r(fn()), 0));

The fallback loses priorities but keeps chunking, which delivers most of the responsiveness benefit. A polyfill package provides a fuller implementation if you need priority ordering everywhere.

Choosing chunk sizes

Chunks that are too large defeat the purpose; chunks that are too small waste time in scheduling overhead and in re-entering Wasm. Measure the throughput of the job with different budgets on a mid-range device: typically, throughput is within a few percent of the unchunked job down to chunks of about 4 ms, and falls off below that. Aim for chunk durations of 5–10 ms, and if work items vary widely in cost, use a time-based budget inside the Wasm function rather than a fixed item count.

When to move the work to a worker instead

Scheduling helps when the work must touch main-thread state or is light enough to interleave. When the work is heavy — seconds of CPU — and its input and output can be transferred, a worker is better: it runs in parallel and does not compete with input at all. Many applications use both: workers for heavy jobs, scheduled chunks for the work that has to happen on the main thread, such as applying results to the DOM or to a document model the UI reads synchronously.

Yielding from inside Rust

Chunking in JavaScript requires the Wasm side to expose a resumable step function, which is natural for queues but awkward for recursive algorithms. An alternative keeps the loop in Rust: make the Rust function async, and await a yield between units of work. With wasm-bindgen, import scheduler.yield (or the fallback) as a function returning a promise and wrap it in JsFuture:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = scheduler, js_name = yield)]
    fn scheduler_yield() -> js_sys::Promise;
}

pub async fn index_all(docs: Vec<Doc>) {
    let mut since_yield = now_ms();
    for doc in docs {
        index(doc);
        if now_ms() - since_yield > 8.0 {
            let _ = wasm_bindgen_futures::JsFuture::from(scheduler_yield()).await;
            since_yield = now_ms();
        }
    }
}

The state lives in the future rather than in a struct, so recursive or deeply nested algorithms can yield without being rewritten as explicit state machines. The cost is that the async function’s state must be 'static, and that each yield resumes through the future machinery. Where JSPI is available, an imported async function can also suspend a synchronous Wasm call, which removes even that restructuring, at the cost of browser support.

Measuring the effect

Responsiveness improvements should be measured, not assumed. Record INP and long tasks (the Long Animation Frames API reports script attribution, including Wasm functions) before and after chunking, on a realistic device, during the scenario users complain about. A good result shows no long animation frames attributed to the Wasm job, and INP dominated by the input handlers themselves.

Expected output

Indexing a 5,000-document workspace runs as a background job in 8 ms chunks; typing during indexing shows INP under 60 ms instead of 320 ms; focusing the search box raises the job’s priority and indexing finishes 40% sooner; and edits abort stale highlighting jobs without errors.

Gotchas

  • Unbounded Wasm calls. One long call blocks input regardless of priority. Chunk the work.
  • Yielding with setTimeout only. Clamped delays and no priority. Use scheduler.yield() where available.
  • Not handling AbortError. Aborted tasks reject. Catch and ignore them.
  • Partial state after abort. The Wasm side may need resetting. Expose a reset().
  • Chunks too small. Overhead dominates. Measure throughput against chunk size.
  • Assuming improvement without measuring. Record INP and long animation frames before and after chunking on a real device.

Performance note

On a mid-range Android phone, INP during background indexing fell from 320 ms with one long Wasm call to 58 ms with 8 ms chunks and scheduler.yield(); total indexing time rose by 6%.

Interaction to Next Paint during background indexing Milliseconds of INP measured while typing during a background indexing job on a mid-range Android phone, with the job as one long Wasm call and as scheduled 8 millisecond chunks with yields. INP in ms one long Wasm call 320 ms 8 ms chunks + yield 58 ms

Frequently Asked Questions

Can postTask interrupt a running Wasm call? No — nothing can. Priorities only affect what runs between calls.

Does scheduler.yield() work in workers? Where the API is supported in workers, yes; in workers responsiveness matters less.

What about isInputPending? It lets a loop yield only when input is waiting; it is Chromium-only and less general than yielding regularly.

Which priority for rendering work? user-visible for work whose result appears on screen soon; user-blocking only for what the user is actively waiting on.

Can Rust code yield without a step function? Yes — make it async and await scheduler.yield() through JsFuture every few milliseconds.

How do I see which Wasm calls cause long frames? The Long Animation Frames API attributes script time to functions, including Wasm exports; the DevTools Performance panel shows the same per task.

Does chunking slow the job down? Slightly — usually a few percent with 5–10 ms chunks, in exchange for input that stays responsive throughout.

← Back to Async & Event-Loop Integration