Sometimes work must stay near the DOM or is too small to justify a worker, yet it is too large for one uninterrupted task. Cooperative scheduling processes a bounded chunk, yields to a future task, then continues. A timer-based yield is widely available; a scheduling API may offer better continuation priority where supported, but any feature-specific path needs a fallback. Choose chunk size from measured work time, not a fixed item count alone: one record may cost far more than another. Preserve cancellation and result ordering across yields, since the route can change while a batch is paused.
Cooperative Main-Thread Scheduling
Working case
A review page highlights 62 visible search snippets. Each highlight is short, but processing all in one event handler delays focus response after a filter change. Chunk the work until a small time budget is used, then allow another task and rendering opportunity. If a second filter request arrives, stop the old generation and start again from its own records. Keep the result hidden or explicitly partial until the required pieces are ready, so a user does not mistake half-highlighted matches for a complete search response.
Implementation boundary
async function summarizeInChunks(records, isCurrent, consumeRecord) {
let checkpoint = performance.now();
for (const record of records) {
if (!isCurrent()) return false;
consumeRecord(record);
if (performance.now() - checkpoint >= 12) {
await new Promise(resolve => setTimeout(resolve, 0));
checkpoint = performance.now();
}
}
return true;
}The caller supplies current-generation ownership and record work; the function stops when that owner changes. The 12-millisecond target is an illustrative starting point, not a universal frame budget. Browser timers may be delayed, especially in background tabs, so do not use this loop for a time-critical background guarantee. The function must handle thrown errors and set a final UI state in the caller. If supported scheduling primitives are adopted, feature-detect them and compare real interaction behavior with the timer fallback before claiming improvement.
Cost and boundaries
The loop still performs O(n) record work and O(1) additional state besides the caller's results, but each yield adds task scheduling overhead and increases total completion time. Smaller chunks improve input opportunity while extending the work; larger chunks finish sooner but can create long tasks. A browser background tab may throttle timers, making total duration much longer. Measure both time to completion and responsiveness under a realistic record mix. If per-record work is itself too expensive, a worker is a better boundary.
Failure trace
A developer writes records.forEach(async record => { await yieldNow(); consumeRecord(record); }) and assumes the outer operation waits. It does not; the UI reports completion while many callbacks are still pending, and errors escape the intended handler. Use a sequential for...of loop and await each yield. Another failure is forgetting generation checks after a yield, so old highlights appear on a new query. Verify cancellation before each record and after any route change.
Verification
- A second control receives input while a long list is processed.
- Changing the query during a yield stops old work before it writes new UI.
- The caller reports completion only after all records finish and handles errors distinctly.
Practice drill
Instrument a list with short and long records. Try chunk targets of 6, 12, and 24 milliseconds and compare response to a second click, total completion time, and output count. Change the active query during a yield and verify the old generation stops. Throw an error in one record and confirm the caller reports failure without claiming a complete list. Repeat in a background tab to document timer behavior rather than assuming the foreground timing applies everywhere.
Decision note
Chunk main-thread work when measured interaction delay warrants it, and keep each resumed batch tied to the current route or task generation.
Common Mistakes
- Using forEach with async callbacks as a sequential work loop.
- Yielding after every trivial record without measuring scheduling overhead.
- Continuing stale work after a route change.
Connected lessons
Browser Execution and Resource Lifecycle; Event Loop Tasks, Microtasks, and Paint; Workers, Messages, Transfer, and Cancellation; Browser Memory and Resource Lifecycles; Event Loop Tasks, Microtasks, and Paint; Workers, Messages, Transfer, and Cancellation; Page performance: budget the critical path and reserve layout space.
Apply and check
Build Project: responsive import worker and review Web Development: layout and runtime contracts quiz.
Further connections
Stream Backpressure and Bounded Work.
Further connections
Wasm Worker Jobs, Cancellation, and Result Order.
Further connections
Interaction Latency Traces and Main-Thread Contention.
