DEV Community

Ankur Dubey
Ankur Dubey

Posted on AI-assisted

Your loop yields every 500 items. It still freezes. Here's why (and a fix)

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. await doesn't help. Awaiting an already-resolved promise only runs microtasks. Timers, I/O and rendering still wait for the whole loop.
  2. Yielding every row works, but you pay per yield. setImmediate adds ~40% here. setTimeout(0) is clamped to 1 ms in Node (4 ms in nested browser timers), so the job takes 27x longer.
  3. 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});
});
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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});
Enter fullscreen mode Exit fullscreen mode

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; },
});
Enter fullscreen mode Exit fullscreen mode

How it yields

yieldToEventLoop() picks the best primitive for the runtime:

  • scheduler.yield() in Chromium
  • setImmediate in Node.js and Bun
  • MessageChannel elsewhere (avoids the 4 ms nested setTimeout clamp)
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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)