Issac Lau

How browser works part 3: split long running tasks

May 5, 2025
12 min read
Loading

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:

  • shouldYield splits 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.

评论

Loading