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 was an overflow menu that opened a modal. On initial render, the overflow menus queued a setTimeout with 0ms delay to trigger change detection. At first glance, everything seemed fine, the page rendered blazingly fast. But when I attempted to toggle the overflow menu, there was a delay. The delay increased when I tried to open the overflow menu further down the table. I had no idea what was causing this delay.
After debugging, I removed the setTimeout from the overflow menus entirely, and boom! Everything started working as expected. How could that small change fix the issue? That’s when I discovered something fascinating about how browsers implement timers (setTimeout, setInterval, etc.). The HTML5 specifications define adding a minimum delay for nested setTimeout, known as clamping. Additionally, the event loop plays a major role in scheduling tasks, so a delay of 4ms won’t precisely execute the callback after 4ms. The behaviors apply to all macro tasks (e.g., timers, user interactions, external script loading). There’s also the fact that Angular patches setTimeout, but that’s can be discussed another time.
Clamping
Clamping is a way to prevent the master thread from getting blocked. JavaScript is a single-threaded language with only one call stack. Browsers enforce a minimum delay between consecutive nested callbacks.
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) {}
If you specify a delay of 0ms, the browser does not guarantee the callback will execute immediately. The callback would only be executed after the current tasks have finished.
Next time you’re debugging something strange with timers, remember that a lot is happening under the hood. I hope this little deep dive has been helpful.
See you next time! 🚀