A while back, I came across an interesting scenario while working on some legacy Angular code. The code used setTimeout
with a 0ms delay to keep the view and state in sync (a hacky way to trigger change detection).
Scenario
We had an application rendering a table with n (100, 150, 200, etc.) rows and 8 columns. The last column contained an overflow menu that opened a modal. On initial render, each overflow menu queued a setTimeout with a 0ms delay to trigger change detection. Everything seemed fine at first glance, the page rendered blazingly fast. But when I tried to toggle the overflow menu, I noticed a delay. Strangely, the delay increased when opening menus further down the table. I had no idea what was causing this issue.
After debugging, I removed the setTimeout from the overflow menus entirely, and boom! Everything started working as expected. How could such a small change make such a big difference?
That’s when I discovered something fascinating about how browsers handle timers (setTimeout, setInterval, etc.). The HTML5 specifications define adding a minimum delay for nested setTimeout calls, a behavior known as clamping. Additionally, the event loop doesn’t guarantee that a delay of 4ms will execute exactly after 4ms because timers, user interactions, and external scripts are all scheduled as macro tasks. This means their execution can be affected by other queued tasks, causing unintended delays.
There’s also the fact that Angular patches setTimeout, but that’s another blog post!
Clamping
Clamping helps prevent the master thread from getting blocked, ensuring smoother execution. Since JavaScript is single-threaded with only one call stack, excessive nested callbacks can cause performance issues. To mitigate this, browsers enforce a minimum delay between consecutive setTimeout calls, preventing them from overwhelming the event loop.
Consider this example:
console.time('Chained Timers');
let count = 0;
function chainTimeouts() {
if (count++ < 10) {
// Chained calls with 0ms delay
setTimeout(chainTimeouts, 0);
} else {
// Logs way more than 0ms * 10
console.timeEnd('Chained Timers');
}
}
chainTimeouts();
Even if you specify a delay of 0ms, the browser does not guarantee the callback will execute immediately. Instead, it clamps the delay to a minimum threshold (typically 4ms) to prevent the JavaScript event loop from being overwhelmed by rapid-fire callbacks that could block the UI.
Event loop
The event loop runs in cycles, processing tasks one by one. It handles macro tasks (e.g., setTimeout, setInterval, user interactions, external script loading), performs rendering or painting, and then reiterates. This means the app interface and logic share the same event loop.
Consider this example:
console.time('Elapsed time');
let start = performance.now();
setTimeout(() => {
// Callback will execute after 50ms, so elapsed time
// will be greater than 50ms
console.timeEnd('Elapsed time');
}, 0);
// Simulate a heavy task
while (performance.now() - start < 50) {}
In the above example, the callback will execute after the heavy tasks has finished and a new cycle has begun.
Next time you’re debugging something strange with timers, remember that a lot is happening under the hood. I hope this short dive into clamping & timers has been helpful.
See you next time! 🚀