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-vitalslibrary.
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.
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.
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
setTimeoutonly. Clamped delays and no priority. Usescheduler.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%.
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.
Related
- Keeping the UI responsive during long Wasm tasks — chunking and workers.
- Debouncing Wasm calls from user input — fewer calls to schedule.
- Cancelling long-running Wasm work — abort patterns.
- Timing out slow Wasm calls — deadlines.
← Back to Async & Event-Loop Integration