Understanding the 4 ms Minimum Delay of setTimeout and Implementing a Zero‑Delay Timer with postMessage
This article explains why browsers enforce a minimum 4 ms delay for nested setTimeout calls, demonstrates how to bypass it using window.postMessage to create a true zero‑delay timer, and compares the performance of both approaches with detailed experiments and visualizations.
Many developers know that setTimeout has a minimum delay; according to the MDN documentation the browser enforces a 4 ms floor, especially when timers are nested more than five levels deep.
In browsers, each call to setTimeout / setInterval has a minimum interval of 4 ms, usually caused by deep nesting.
The HTML Standard further specifies that after five nested timers the interval is forced to be at least four milliseconds.
To illustrate this, the following code measures the elapsed time between successive nested setTimeout calls:
let a = performance.now();
setTimeout(() => {
let b = performance.now();
console.log(b - a);
setTimeout(() => {
let c = performance.now();
console.log(c - b);
setTimeout(() => {
let d = performance.now();
console.log(d - c);
setTimeout(() => {
let e = performance.now();
console.log(e - d);
setTimeout(() => {
let f = performance.now();
console.log(f - e);
setTimeout(() => {
let g = performance.now();
console.log(g - f);
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);Running this in a browser shows that the fifth execution incurs a delay exceeding 4 ms, matching the specification.
Exploration
If an “immediate” timer is needed, one can avoid the 4 ms floor by using window.postMessage . The technique is described in a referenced article and works because postMessage callbacks are macro‑tasks, just like setTimeout , but they are not subject to the same minimum‑delay restriction.
The implementation creates a setZeroTimeout function that queues callbacks and triggers them via a custom message event:
(function(){
var timeouts = [];
var messageName = 'zero-timeout-message';
function setZeroTimeout(fn){
timeouts.push(fn);
window.postMessage(messageName, '*');
}
function handleMessage(event){
if(event.source == window && event.data == messageName){
event.stopPropagation();
if(timeouts.length > 0){
var fn = timeouts.shift();
fn();
}
}
}
window.addEventListener('message', handleMessage, true);
window.setZeroTimeout = setZeroTimeout;
})();Because both postMessage and setTimeout are macro‑tasks, this approach yields a timer with a much smaller effective delay.
Test
An experiment compares the zero‑delay timer against the traditional setTimeout by recursively counting to 100 with each method and measuring the total time.
function runtest(){
var output = document.getElementById('output');
var outputText = document.createTextNode('');
output.appendChild(outputText);
function printOutput(line){ outputText.data += line + '\n'; }
var i = 0;
var startTime = Date.now();
function test1(){
if(++i == 100){
var endTime = Date.now();
printOutput('100 iterations of setZeroTimeout took ' + (endTime - startTime) + ' ms.');
i = 0; startTime = Date.now();
setTimeout(test2, 0);
} else {
setZeroTimeout(test1);
}
}
setZeroTimeout(test1);
function test2(){
if(++i == 100){
var endTime = Date.now();
printOutput('100 iterations of setTimeout(0) took ' + (endTime - startTime) + ' ms.');
} else {
setTimeout(test2, 0);
}
}
}On the author’s Mac, the zero‑delay version is roughly 80–100 times faster than the standard setTimeout version, and on a more powerful desktop the difference can exceed 200×.
Performance Panel
Using the browser’s Performance panel visualizes the distribution of callbacks. The postMessage timer fires densely within 5 ms, while the setTimeout timer shows a sparse pattern with the first four calls around 1 ms and the fifth exceeding 4 ms.
Application
Zero‑delay timers are useful in scenarios such as React’s Scheduler, where tasks are sliced into small chunks. React uses MessageChannel (a variant of postMessage ) to schedule macro‑tasks, allowing the main thread to regain control for higher‑priority rendering work.
const channel = new MessageChannel();
const port = channel.port2;
// Each port.postMessage() adds a macro‑task
channel.port1.onmessage = scheduler.scheduleTask;
const scheduler = {
scheduleTask(){
const task = pickTask();
const continuousTask = task();
if(continuousTask){
port.postMessage(null);
}
}
};Macro‑tasks are preferred over micro‑tasks for time‑slicing because micro‑tasks run before rendering, which could block urgent UI updates.
Summary
The 4 ms minimum delay of setTimeout originates from browser specifications and appears after five nested timers.
Using postMessage you can implement a true zero‑delay timer.
This zero‑delay timer is employed in React’s time‑slicing mechanism.
Macro‑tasks (via postMessage or MessageChannel ) are chosen for scheduling because they do not block rendering like micro‑tasks do.
ByteFE
Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.