Chunking CPU Work with setImmediate Keeps the Event Loop Breathing
Sync vs Async Performance
Synchronous work blocks the event loop no matter how many Promises you wrap it in — learn when async genuinely helps, when it only adds overhead, and how to chunk large algorithms with setImmediate.
What you'll learn
- Explain why wrapping sync work in a Promise does not unblock the event loop
- Chunk a long-running sort into cooperative pieces using setImmediate
- Know the scenarios where true async (I/O, worker threads) actually helps latency
Wrapping a slow algorithm in async function or Promise does not make it
non-blocking. Async syntax only changes when the function is called relative
to the microtask queue — the CPU work inside still runs synchronously on the
same thread. This lesson draws a clear line between async that helps and async
that is just decoration.
The Promise Wrapper Myth
// This does NOT unblock the event loop — the loop is stuck inside the Promise
async function slowSort(arr) {
arr.sort((a, b) => a - b); // still synchronous
return arr;
}
await slowSort(hugeArray); // await just defers calling, CPU is still hogged The await before the call yields to the microtask queue for one tick, then
immediately resumes. All the CPU time for the sort runs in that resumed
microtask — the poll phase never gets a look in.
When Async Actually Helps
Async is a genuine win only when the work is off-thread:
| Situation | Blocking? | Fix |
|---|---|---|
fs.readFileSync | Yes | Use fs.promises.readFile |
| Database query (sync driver) | Yes | Use async driver |
| CPU-bound sort in main thread | Yes | Worker thread or chunking |
fetch / network call | No (awaits kernel) | Already non-blocking |
setTimeout(fn, 0) | No | Yields to timer phase |
Chunking with setImmediate
setImmediate fires in the check phase, after the poll phase has had a
chance to grab waiting I/O. Yielding to it between chunks of work lets the loop
breathe.
/**
* Process a large array in chunks of `size` items.
* Between each chunk the event loop can service pending I/O.
*/
function processInChunks(items, size, processFn) {
return new Promise((resolve) => {
let i = 0;
function next() {
const end = Math.min(i + size, items.length);
while (i < end) {
processFn(items[i]);
i++;
}
if (i < items.length) {
setImmediate(next); // yield to the event loop, then continue
} else {
resolve();
}
}
next();
});
}
// Example: sum 2 million numbers without blocking
const data = Array.from({ length: 2_000_000 }, (_, k) => k);
let total = 0;
await processInChunks(data, 50_000, (n) => { total += n; });
console.log(total); Chunk size is a tuning knob: larger chunks mean less overhead but longer individual blocks; smaller chunks keep latency low but add more setImmediate round-trips.
When Chunking Adds Overhead Without Benefit
Chunking is not free — each setImmediate call has a small cost. Avoid it
when the work is already fast:
- Sorting 1 000 items takes under 1 ms; no chunking needed.
- A single pass over a 10-item array is negligible.
- Internal batch jobs with no latency requirement can run sync.
Reserve chunking (or worker threads, covered next) for operations that measurably block the loop under real load.
Up Next
When chunking still is not fast enough, move CPU-bound work entirely off the main thread with worker threads.
Worker Threads for CPU-Bound Algorithms →