Skip to content

timers: prevent setTimeout from firing callback early#62466

Open
vijaygovindaraja wants to merge 1 commit intonodejs:mainfrom
vijaygovindaraja:fix/timers-prevent-early-callback
Open

timers: prevent setTimeout from firing callback early#62466
vijaygovindaraja wants to merge 1 commit intonodejs:mainfrom
vijaygovindaraja:fix/timers-prevent-early-callback

Conversation

@vijaygovindaraja
Copy link
Copy Markdown

Summary

  • Fixes setTimeout occasionally firing its callback before the requested delay has elapsed
  • Root cause: libuv's uv_now() truncates sub-millisecond time to integer milliseconds, causing up to 1ms of "stolen" time when scheduling timers
  • Fix: add 1ms to the duration passed to uv_timer_start() in Environment::ScheduleTimer

The Bug

When a timer is scheduled at a fractional millisecond boundary:

  1. Real time: 100.7msuv_now() returns 100 (truncated)
  2. Timer fires when uv_now() >= 100 + delay
  3. When uv_now() reaches 100 + delay, real elapsed time is only delay - 0.7ms
  4. Measured with process.hrtime() or Date.now(), the callback appears to fire early

This is especially likely when setImmediate() is involved (affects uv_update_time() timing), as identified by @bnoordhuis.

The Fix

src/env.ccEnvironment::ScheduleTimer:

// Before
uv_timer_start(timer_handle(), RunTimers, duration_ms, 0);

// After — compensate for uv_now() truncation
uv_timer_start(timer_handle(), RunTimers, duration_ms + 1, 0);

This covers both scheduling paths:

  • Initial scheduling from JS (binding.scheduleTimer(msecs) in lib/internal/timers.js)
  • Rescheduling after timer processing (RunTimers in src/env.cc)

Trade-off: Up to 1ms additional latency on timers. This is acceptable because:

  • The HTML spec already specifies a 4ms minimum for nested timeouts
  • Timer precision was never sub-millisecond in Node.js (libuv uses ms internally)
  • The guarantee that setTimeout(fn, N) fires after ≥N ms is more important than minimizing latency

No compounding for setInterval: Each interval resets _idleStart to fresh uv_now() (line 599/611 of lib/internal/timers.js), so the +1ms does not accumulate across intervals.

Reproduction

From @Trott's comment:

const assert = require('assert');
const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const test = async () => {
    const MS = 100;
    const start = process.hrtime();
    await timeout(MS);
    const diff = process.hrtime(start);
    const actual = ((diff[0] * 1e9) + diff[1]) / 1000000;
    assert(actual > MS, `${actual} ${MS}`);
    setImmediate(test);
};
test();

Fails within ~20 seconds on macOS, Windows, and Linux (confirmed across Node.js 8.x through 24.x).

Fixes: #26578

libuv's uv_now() truncates sub-millisecond time to integer
milliseconds. When a timer is scheduled at a fractional millisecond
boundary (e.g., real time 100.7ms, uv_now() returns 100), the timer
can fire up to 1ms before the requested delay has actually elapsed
when measured with high-resolution clocks like process.hrtime() or
Date.now().

Add 1ms to the duration passed to uv_timer_start() in
Environment::ScheduleTimer to compensate for this truncation. This
ensures that setTimeout/setInterval callbacks never fire before the
requested delay, at the cost of up to 1ms additional latency.

The fix is applied at the C++ boundary (ScheduleTimer) rather than in
JavaScript because it covers both scheduling paths: the initial
schedule from JS (binding.scheduleTimer) and the rescheduling after
timer processing in RunTimers.

Fixes: nodejs#26578
Signed-off-by: V Govindarajan <vijay.govindarajan91@gmail.com>
@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. labels Mar 27, 2026
@vijaygovindaraja
Copy link
Copy Markdown
Author

Hi @nodejs/timers — this addresses the 7-year-old early-fire bug discussed in #26578. The fix adds 1ms to the duration in ScheduleTimer to compensate for uv_now() truncation, as @Fishrock123 and @apapirovski suggested. Happy to discuss the approach or make adjustments.

@apapirovski
Copy link
Copy Markdown
Contributor

apapirovski commented Mar 27, 2026

The current implementation, as far as I can tell, is compatible with the setTimeout and time spec already. I'm not sure the value added by 1ms offset besides trying to enforce conformance to a behavior that isn't guaranteed by the spec. This also does nothing to solve the WSL problems that timers face, which are root caused to a similar problem around different clocks & granularities of measurement, and so I'm -1 on any alteration like this.

@vijaygovindaraja
Copy link
Copy Markdown
Author

@apapirovski Thanks for reviewing — I appreciate the detailed feedback.

You're right that the HTML spec says the delay is a minimum and doesn't guarantee exact timing. However, the current behavior violates even that minimum guarantee — setTimeout(fn, 100) can fire at 99.6ms as measured by process.hrtime(), which is before the minimum delay.

That said, I understand the concern about adding a blanket +1ms offset for a sub-millisecond edge case. A few questions to help me understand if there's a path forward:

  1. Would a documentation-only change be more appropriate? The Node.js timers docs say "the callback will not be invoked sooner than the timeout milliseconds" — but with millisecond truncation, it can be invoked sooner. Documenting this would at least set correct expectations.

  2. Back in 2022 you mentioned potentially switching to uv_hrtime for timers. Is that still a viable direction, or has the thinking changed?

Happy to close this PR and pursue whichever approach the team prefers. Thanks again for your time.

@apapirovski
Copy link
Copy Markdown
Contributor

apapirovski commented Mar 27, 2026

By my reading of the spec, technically the timer is only required to be accurate when compared to the same clock with the same granularity (i.e., ms). The inherent problem is that everyone seems to want to measure using different granularity which nothing guarantees.

uv_hrtime is likely to be expensive performance wise but I haven't tried it. Updating documentation could be reasonable.

@vijaygovindaraja
Copy link
Copy Markdown
Author

That makes sense — the spec guarantees accuracy within the same clock granularity, and the mismatch comes from users measuring with higher-precision clocks.

I'll put together a documentation PR to clarify this behavior in the timers docs. Something along the lines of:

Timer delays are tracked with millisecond precision internally. When measured with sub-millisecond clocks such as process.hrtime() or performance.now(), callbacks may appear to fire up to 1ms earlier than the requested delay due to rounding at the millisecond boundary.

Would that framing work, or would you prefer different wording? Happy to adjust.

@apapirovski
Copy link
Copy Markdown
Contributor

Seems reasonable. Keep in mind I'm no longer an approver here so take my 2c for whatever they're worth haha. Hopefully a current set of maintainers can chime in.

@vijaygovindaraja
Copy link
Copy Markdown
Author

Following the discussion here, I've opened a docs-only PR at #62468 to clarify the millisecond precision behavior. Thanks @apapirovski for the guidance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

setTimeout Calling Callback Too Early

3 participants