Every JavaScript developer eventually writes this loop:
for (const [index, row] of rows.entries()) {
processRow(row);
if (index % 500 === 0) await new Promise(resolve => setImmediate(resolve));
}
It's the textbook fix for a long loop that freezes the UI or stalls a server: do some work, give the event loop a turn, repeat.
It works in testing. It works in staging. Then production sends you a run of slow rows, and the app freezes anyway.
I hit exactly this, so I built a tiny library to fix it: time-sliced. This post is about why the usual fix breaks, what I did instead, and — most importantly — I'd like your feedback on it.
Why we built it
Picture a loop over a big array of records: building a search index, transforming API results, validating a CSV import, parsing JSON payloads. Most items are tiny and take microseconds. A few are heavy: a deeply nested object, a huge text field, a row that takes a slow code path.
We had the "yield every 500" pattern in place. It looked fine in testing, because test data is uniform. But real data isn't. When a cluster of heavy items showed up together, one chunk of 500 suddenly took a few hundred milliseconds. During that time:
- the app beachballed,
- timers fired late,
- other requests waited, and some timed out.
The loop was yielding. It just wasn't yielding often enough when it mattered.
The root problem: "every N items" assumes every item costs the same. Real data doesn't work that way.
The idea: yield on time, not on count
Instead of counting items, measure time. Do work until a budget is spent (8 ms by default), then yield. Start a new slice and continue.
- When items are cheap, you yield rarely, so overhead stays low.
- When items are slow, you yield often, so nothing freezes.
import {timeSlicedForEach} from 'time-sliced';
await timeSlicedForEach(rows, processRow);
That's the whole API for the common case.
The numbers
One job, run with each common "keep Node responsive" strategy: 20,000 rows at 0.02 ms each, with a run of 1,000 heavy rows at 0.5 ms in the middle. A 1 ms heartbeat timer measures the longest event-loop freeze. Median of 3 runs, Node.js 24.
| Strategy | Total time | Longest freeze | Result |
|---|---|---|---|
| Plain loop | 897 ms | 897 ms | freezes |
await every row |
887 ms | 887 ms | freezes |
async.eachSeries |
882 ms | 882 ms | freezes |
setTimeout(0) every row |
24.6 s | 16 ms | responsive, 27x slower |
setImmediate every row |
1286 ms | 6 ms | responsive, 1.4x slower |
setImmediate every 500 rows |
908 ms | 254 ms | freezes |
| time-sliced | 921 ms | 11 ms | responsive and fast |
A few things surprised people when I showed them this:
-
awaitdoesn't help. Awaiting an already-resolved promise only runs microtasks. Timers, I/O and rendering still wait for the whole loop. -
Yielding every row works, but you pay per yield.
setImmediateadds ~40% here.setTimeout(0)is clamped to 1 ms in Node (4 ms in nested browser timers), so the job takes 27x longer. - Every-N is fast until the rows get slow. 500 cheap rows = 10 ms. 500 heavy rows = 250 ms. That's the freeze users see.
time-sliced finishes in about the same time as the plain loop, and the event loop never freezes much longer than the budget.
Real-world usage
Keep a server answering during an import
app.post('/import', async (request, response) => {
const rows = parseCsv(request.body);
const records = await timeSlicedMap(rows, row => toRecord(row), {budgetMs: 25});
await database.insertMany(records);
response.json({imported: records.length});
});
Health checks and other API calls keep getting answered during a 50,000-row import. On a server, nothing needs 60 fps, so a bigger budget (25 ms) means fewer yields.
Your own loops: nested, while, recursive
When the work doesn't fit a map, use a slicer:
import {createTimeSlicer} from 'time-sliced';
const slicer = createTimeSlicer();
for (const category of categories) {
for (const product of category.products) {
updateSearchIndex(product);
if (slicer.isSpent()) await slicer.yield();
}
}
Cancel with AbortController
Great for search-as-you-type: abort the previous search, and it stops at its next yield instead of finishing work nobody will see.
const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
await timeSlicedForEach(rows, processRow, {signal: controller.signal});
Test it without real timers
Inject a fake clock and yield function to assert how often your code yields:
let nowMs = 0;
let yields = 0;
await timeSlicedForEach(rows, () => {
nowMs += 2; // each row "takes" 2 ms
}, {
now: () => nowMs,
yieldToEventLoop: async () => { yields += 1; },
});
How it yields
yieldToEventLoop() picks the best primitive for the runtime:
-
scheduler.yield()in Chromium -
setImmediatein Node.js and Bun -
MessageChannelelsewhere (avoids the 4 ms nestedsetTimeoutclamp) -
setTimeout(0)as a last resort
What it does not do
Being honest about the limits:
- One item longer than the budget still blocks for that long. If a single item takes 200 ms, move it to a worker thread or split it.
- It doesn't make work faster. It lets everything else run in between. Total time stays about the same.
-
Overhead is ~30 ns per item (one
performance.now()call). For millions of trivial items, batch a few per callback.
Try it
npm install time-sliced
- Zero dependencies
- Node.js 22+, Bun, Deno, browsers, Electron (main and renderer)
- ESM + CommonJS, TypeScript types included
- MIT licensed
📦 npm: https://www.npmjs.com/package/time-sliced
💻 GitHub: https://github.com/RagedPanda/time-sliced
I'd love your feedback
This is an early release (v0.1), and I want to hear from people who've fought the event loop:
- Is 8 ms the right default budget? What do you use on servers?
- Have you hit cases where time-based yielding behaved worse than count-based?
- Which runtime or environment should I test next?
- Is the API (
timeSlicedMap,timeSlicedForEach,createTimeSlicer) clear, or would you name things differently?
Drop a comment, open an issue, or run npm run bench on your machine and share your numbers. Stars on GitHub help others find it too. Thanks for reading!
Top comments (0)