This is the third article in the series on understanding how browsers work. Previously we covered the browser architecture and the event loop. Even with the event loop, long-running synchronous work can still block the main thread and make an app feel sluggish.
This article explores how to split long-running tasks using existing browser mechanisms so apps stay smooth.
A long-running task
function factorial(n: number) {
if (n === 0 || n === 1) {
return 1n;
}
let result = 1n;
for (let i = 2n; i <= n; i++) {
result *= i;
}
return result;
}
function App() {
const [result, setResult] = useState<number>();
const [len, setLen] = useState<number>();
return (
<div>
<Button
onClick={()=> {
setResult(undefined);
setLen(undefined);
const startTime= performance.now();
const result= factorial(120000);
console.log(`Factorial digits: ${result.toString().length}`);
setLen(result.toString().length);
const endTime= performance.now();
const duration= endTime - startTime;
console.log(`Factorial duration: ${duration} ms`);
setResult(duration);
}}
>
Compute 120000 factorial
</Button>
<div className="break-words">
<div>Duration: {result}</div>
<div>Digits: {len}</div>
</div>
</div>
);
}
When you click the button, the browser freezes for a while and cannot respond to other events (try selecting text on the page).
Split the long-running task
A natural approach is to split the work into smaller chunks and run them one after another.
function factorialChunked(
n: number,
chunkSize = 1000,
onProgress?: (current: number, total: number) => void,
onComplete?: (result: bigint) => void
) {
if (n === 0 || n === 1) {
onComplete?.(1n);
return;
}
let result = 1n;
let current = 2;
function computeChunk() {
// Calculate the end of the current chunk
const end = Math.min(current + chunkSize - 1, n);
// Compute the factorial for the current chunk
while (current <= end) {
result *= BigInt(current);
current++;
}
// Update progress
onProgress?.(current - 1, n);
// If there is more work, continue with the next chunk
if (current <= n) {
// Yield the main thread and continue later
setTimeout(computeChunk, 0);
} else {
// Done, return result
onComplete?.(result);
}
}
computeChunk();
}
Here we split the task into chunks using setTimeout, running each chunk after the previous one.
If you click the button and try to select text now, it works.
The issue with setTimeout is that nesting more than 4 levels introduces a ~4.7ms delay. A better way to create macro tasks is to use MessageChannel.
Split the long-running task with MessageChannel
function factorialChunkedMessageChannel(
n: number,
chunkSize = 1000,
onProgress?: (current: number, total: number) => void,
onComplete?: (result: bigint) => void
) {
const channel = new MessageChannel();
const taskQueue: any[] = [];
channel.port1.onmessage = () => {
const task = taskQueue.shift();
if (task) task();
if (taskQueue.length > 0) {
channel.port2.postMessage(null);
}
};
function scheduleTask(task: () => void) {
taskQueue.push(task);
if (taskQueue.length === 1) {
channel.port2.postMessage(null);
}
}
if (n === 0 || n === 1) {
onComplete?.(1n);
return;
}
let result = 1n;
let current = 2;
function computeChunk() {
// Calculate the end of the current chunk
const end = Math.min(current + chunkSize - 1, n);
// Compute the factorial for the current chunk
while (current <= end) {
result *= BigInt(current);
current++;
}
// Update progress
onProgress?.(current - 1, n);
// If there is more work, continue with the next chunk
if (current <= n) {
// Yield the main thread and continue later
scheduleTask(computeChunk);
} else {
// Done, return result
onComplete?.(result);
}
}
computeChunk();
}
Here we split the task into chunks using MessageChannel, running each chunk after the previous one.
Split the long-running task with scheduler
Task scheduling is core to React, so React provides scheduler to solve this problem.
import {
unstable_scheduleCallback as scheduleCallback,
unstable_shouldYield as shouldYield,
unstable_NormalPriority as NormalPriority,
} from "scheduler";
function factorialChunkedScheduler(
n: number,
chunkSize = 1000,
onProgress?: (current: number, total: number) => void,
onComplete?: (result: bigint) => void
) {
if (n === 0 || n === 1) {
onComplete?.(1n);
return;
}
let result = 1n;
let current = 2;
function computeChunk() {
// Calculate the end of the current chunk
const end = Math.min(current + chunkSize - 1, n);
// Compute the factorial for the current chunk
while (current <= end) {
result *= BigInt(current);
current++;
}
// Update progress
onProgress?.(current - 1, n);
// If there is more work, continue with the next chunk
if (current <= n) {
// Yield the main thread and continue later
scheduleCallback(NormalPriority, computeChunk);
} else {
// Done, return result
onComplete?.(result);
}
}
computeChunk();
}
Here we split the task using scheduler. After each chunk completes, we call scheduleCallback to run the next chunk.
Smoother splitting with React scheduler
React's scheduler provides shouldYield, which can split work more smoothly.
import {
unstable_scheduleCallback as scheduleCallback,
unstable_shouldYield as shouldYield,
unstable_NormalPriority as NormalPriority,
} from "scheduler";
function factorialChunkedSchedulerSmooth(
n: number,
onProgress?: (current: number, total: number) => void,
onComplete?: (result: bigint) => void
) {
if (n === 0 || n === 1) {
onComplete?.(1n);
return;
}
let result = 1n;
let current = 2;
function computeChunk() {
while (current <= n) {
result *= BigInt(current);
current++;
if (shouldYield()) {
// Update progress
onProgress?.(current - 1, n);
// Yield the main thread and continue later
scheduleCallback(NormalPriority, computeChunk);
return;
}
}
onProgress?.(current - 1, n);
// Done, return result
onComplete?.(result);
}
computeChunk();
}
With this approach, we do not manually split by a fixed loop size (like every 1000 iterations). Instead, we let shouldYield
decide when to yield. The benefits:
shouldYieldsplits work by time (default ~5ms), which avoids manual chunk sizing.- Manual chunking can still block the main thread if the chunk size is too large. For example, if we split by 1000 iterations, later iterations can take longer, and large data sets can still cause jank.
React scheduler continuation pattern
function factorialChunkedSchedulerContinuation(
n: number,
onProgress?: (current: number, total: number) => void,
onComplete?: (result: bigint) => void
) {
if (n === 0 || n === 1) {
onComplete?.(1n);
return;
}
let result = 1n;
let current = 2;
function computeChunk() {
while (current <= n) {
result *= BigInt(current);
current++;
if (shouldYield()) {
// Update progress
onProgress?.(current - 1, n);
// Return a continuation; scheduler will yield and resume later
return computeChunk;
}
}
onProgress?.(current - 1, n);
// Done, return result
onComplete?.(result);
}
scheduleCallback(NormalPriority, computeChunk);
}
Here we only call scheduleCallback once. Each time we need to yield, we return a continuation function and the scheduler resumes later.
Split tasks with scheduler.postTask
The browser provides scheduler.postTask (not supported in Safari) to split work.
import { unstable_scheduleCallback as scheduleCallback } from "scheduler";
const postTask = (function createPostTask() {
if ("scheduler" in globalThis) {
console.log("Feature scheduler.postTask: Supported");
return (task: () => void) => {
(globalThis as any).scheduler.postTask(task);
};
} else {
console.error(
"Feature scheduler.postTask: NOT Supported. use react scheduler instead"
);
return (task: () => void) => {
scheduleCallback(NormalPriority, task);
};
}
})();
function factorialChunkedSchedulerPostTask(
n: number,
chunkSize = 1000,
onProgress?: (current: number, total: number) => void,
onComplete?: (result: bigint) => void
) {
if (n === 0 || n === 1) {
onComplete?.(1n);
return;
}
let result = 1n;
let current = 2;
function computeChunk() {
// Calculate the end of the current chunk
const end = Math.min(current + chunkSize - 1, n);
// Compute the factorial for the current chunk
while (current <= end) {
result *= BigInt(current);
current++;
}
// Update progress
onProgress?.(current - 1, n);
// If there is more work, continue with the next chunk
if (current <= n) {
// Yield the main thread and continue later
postTask(computeChunk);
} else {
// Done, return result
onComplete?.(result);
}
}
computeChunk();
}
Split tasks with scheduler.yield
The browser also provides scheduler.yield (not supported in Safari) to yield more easily.
function yieldToMainThread() {
// Use scheduler.yield if it exists:
if ("scheduler" in globalThis && "yield" in (globalThis as any).scheduler) {
return (globalThis as any).scheduler.yield();
}
// Fall back to setTimeout:
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
async function factorialChunkedSchedulerYield(
n: number,
onProgress?: (current: number, total: number) => void,
onComplete?: (result: bigint) => void
) {
if (n === 0 || n === 1) {
onComplete?.(1n);
return;
}
let result = 1n;
let current = 2;
while (current <= n) {
result *= BigInt(current);
current++;
if (current % 500 === 1) {
// Update progress
onProgress?.(current - 1, n);
await yieldToMainThread();
}
}
onProgress?.(current - 1, n);
// Done, return result
onComplete?.(result);
}
Summary
This article covered several ways to split long-running tasks in the browser, along with code examples.
- Split tasks with
setTimeout - Split tasks with
MessageChannel - Split tasks with
react.scheduler - Split tasks with
scheduler.postTask - Split tasks with
scheduler.yield
Ever wondered how react.scheduler works internally? It uses setTimeout and
MessageChannel depending on the runtime environment.
It also has implementations for scheduler.postTask and scheduler.yield.
As browser support improves, react.scheduler may adopt native scheduler.postTask and scheduler.yield.