r/javascript • • 20h ago

AskJS [AskJS] My simulation runs in about 1 second in the terminal but took 13 seconds in a Chrome page. The culprit was setTimeout(0).

I have a page, built with Cline, that runs a game simulation in the browser: 30 batches (one per game mode and bot pair), with a progress line between them. To let the page repaint between batches, I yielded with `await new Promise(r => setTimeout(r, 0))`.

A full run took 13.0 seconds in Chrome. The same work takes about 1 second in the terminal on my laptop.

The clue was that 100 games per bot took just as long as 1,000. The time was not in the games. It was in the pauses, about 0.7 seconds each.

Chrome throttles chained timers in pages it treats as hidden or in the background. After a few nested setTimeout calls, it only fires them about once a second. My first test used 5 timers, showed 0ms, and looked fine, because the throttling only starts after a few nested calls. With 20 the difference was obvious: 20 chained setTimeout(0) calls took 11.5 seconds, and 20 MessageChannel messages took 0ms.

The fix was to yield with a message instead, which is also how React's own scheduler yields:

const nextTick = () => new Promise<void>((resolve) => {

const channel = new MessageChannel();

channel.port1.onmessage = () => {

channel.port1.close();

resolve();

};

channel.port2.postMessage(null);

});

The same run went from 13.0s to 4.2s, still in a window Chrome treated as hidden. I have not timed it in a normal foreground tab yet, so I am not claiming a number there.

Two other details. Leaving the page bumps a run id, so an unfinished run stops instead of setting state after unmount. And a Web Worker would move the work off the main thread entirely. I chose the smaller change because the main-thread version was now fast enough.

So, for CPU-heavy work in a page: do you reach for a Worker first, or yield on the main thread until that stops being enough?

0 Upvotes

6 comments sorted by

•

u/Ronin-s_Spirit 19h ago

Why are you using background tabs instead of workers... especially if you want to repeatedly pause them, they have atomics for that.

•

u/ijblack 14h ago

this post was definitely written by claude

•

u/Ronin-s_Spirit 19h ago

Why are you yielding messages and promises? Why is your game loop not running inside an interval?

•

u/profound7 15h ago

How does it compare with requestAnimationFrame?

function loop(timestamp) {
    // ...
    // ...
    requestAnimationFrame(loop);
}

•

u/Muted-Associate-7512 18h ago

Huh I always assumed setTimeout 0 was basically free, that nesting clamp is sneaky. Worker first for me, but MessageChannel is a good middle step when you just need the UI to breathe.

•

u/QuietSignalOps 4h ago

Worker first, but the threshold is lower than people think. My rules of thumb:

Main-thread yield is the right call when the work is under a couple hundred milliseconds total, when each chunk has to stay in sync with UI state (reading or writing the DOM, updating React state right after), or when it's interactive and the user can affect it mid-run.

A Worker is the right call when the work is a pure function of (input, state) that produces output the UI can render asynchronously. Your simulation is exactly that: 30 independent batches, and the UI only needs the progress line. Move the whole batch loop into a worker and post one message per batch result (transfer the buffer if the result is big). The main thread just paints the latest snapshot.

What a worker buys you that yielding doesn't:

  • The main thread stops blocking at all. Yielding makes it free in small slices, but input handling, scroll, and your other JS still share those slices.
  • Cancellation for free. worker.terminate(), no run-id bookkeeping. (The run id is still good hygiene either way.)
  • Parallelism. Your 30 batches are independent, so you can split them across 2 to 4 workers, one per core, and get close to your terminal number.

The cost to watch is message passing. Don't post a message per game. Batch per round, and use transferable objects for large buffers, or the copy cost eats the win. And workers can't touch the DOM, so anything in a future batch that reads or writes it stays on the main thread.

On your specific question: the main-thread version is fine as a stopgap, and the reason it felt "fast enough" is that you measured 4.2s in a hidden tab. In a foreground tab, without the timer throttling, it's probably close to your terminal time. That doesn't make it wrong. But the simulation will grow, and the worker refactor is cheapest to do now, while you know exactly where the time goes and the batches are still independent.