Skip to main content

setTimeout 4ms 最小延迟

setTimeout(fn, 0) 的延迟并不是严格意义上的 0 毫秒,特别是当它被嵌套调用(例如在回调函数里再次调用 setTimeout(fn, 0))时,浏览器会强制将最小延迟设为 4ms。这个 4ms 来源于 HTML 规范 和浏览器实现的安全策略。

为什么是 4ms?

1. 规范要求:防止“定时器风暴

根据 HTML Living Standard 的规范:

If the nesting level is greater than 5, and the timeout is less than 4, then set the timeout to 4.

简单翻译:

  • 浏览器会记录当前 setTimeout嵌套深度(即在同一个调用栈中,连续通过 setTimeout 产生的回调次数)。
  • 当嵌套深度 超过 5 层(某些旧规范为 4 层,现代规范为 5 层),并且你设置的延迟时间小于 4ms(比如 0),浏览器会自动将延迟强制改为 4ms

2. 目的:保护性能和功耗

如果没有这个限制,代码可以这样写:

function loop() {
// 做点事情
setTimeout(loop, 0); // 无限递归,每次都尝试立即执行
}
loop();

这会导致浏览器在极短时间内疯狂产生大量宏任务,持续占用 CPU,造成页面卡顿、电池快速消耗,甚至让整个标签页失去响应。4ms 的限制相当于强制给这种“定时器炸弹”一个最低的时间间隙,保证事件循环有机会处理其他任务(如用户输入、渲染、网络等)。

3. 历史渊源:从 10ms 到 4ms

  • 早期浏览器(IE、Firefox 等)对 setTimeout(fn, 0) 的最小延迟是 10ms
  • 后来为了提升性能,Chrome 等浏览器在非嵌套场景下可以将延迟压到 1ms 左右(实际上也不完全是 0,受系统时钟粒度影响)。
  • 但嵌套场景的风险一直存在,经过讨论,最终 HTML 规范采用了 4ms 作为嵌套后的强制最小值。这个数值是一个经验平衡值:既能避免过于频繁的调用,又不会明显影响正常应用的响应速度。

什么情况下会触发 4ms 限制?

  • 嵌套:指在 setTimeout 回调函数内部再次调用 setTimeout(fn, 0),并且这种递归/嵌套的深度达到 5 层以上。
  • 不是任何 setTimeout(fn, 0) 都是 4ms
    第一次调用 setTimeout(fn, 0),如果没有发生嵌套,实际延迟可能是 0~1ms(取决于浏览器和当前负载)。
    例如:
    setTimeout(() => {
    console.log('第一次延迟 < 4ms');
    setTimeout(() => {
    console.log('第二次延迟可能还是 < 4ms'); // 深度2,未超5
    setTimeout(() => {
    // ... 深度达到6时,强制 4ms
    }, 0);
    }, 0);
    }, 0);

验证 4ms 的小实验(在浏览器控制台)

let start = performance.now();
let count = 0;

function testNested() {
let now = performance.now();
console.log(`${++count}次,实际延迟:${now - start}ms`);
start = now;
if (count < 10) setTimeout(testNested, 0);
}
setTimeout(testNested, 0);

你会发现,前几次延迟很小(可能 1ms 左右),大约第 6 次开始,延迟稳定在 4ms 左右。

总结

  • 4ms 不是随意定的数字,而是由 HTML 规范强制规定的嵌套 setTimeout 的最小延迟
  • 目的是防止恶意或意外的递归 setTimeout(fn, 0) 霸占事件循环,保护浏览器性能和用户设备续航。
  • 非嵌套的 setTimeout(fn, 0) 实际延迟通常小于 4ms(但不会是绝对的 0),无法用来保证精准的定时。

如果你想实现真正的“下一个宏任务”且不被 4ms 限制,可以考虑 MessageChannelpostMessage(它们没有这种嵌套限制)。