@nxtedition/timers
v1.1.11
Published
Optimized timer pooling for Node.js. Delays >= 1000ms use lightweight JavaScript handles dispatched by one unref'ed native `Timeout`, reducing native `Timeout` allocation when many long-lived timers are in use.
Maintainers
Keywords
Readme
@nxtedition/timers
Optimized timer pooling for Node.js. Delays >= 1000ms use lightweight JavaScript handles dispatched by one unref'ed native Timeout, reducing native Timeout allocation when many long-lived timers are in use.
Short delays (< 1000ms) fall through to the native setTimeout for full resolution.
Install
npm install @nxtedition/timersUsage
import { setTimeout, clearTimeout } from '@nxtedition/timers'
// Short delays use native setTimeout (full resolution)
const handle = setTimeout(
(data) => {
console.log(data)
},
50,
'hello',
)
// Long delays use pooled timers (500ms cadence, lower allocation overhead)
const pooled = setTimeout(
(data) => {
console.log(data)
},
5000,
'world',
)
// Clear works for both
clearTimeout(handle)
clearTimeout(pooled)Opaque data
The third argument is passed directly to the callback, avoiding closure allocations:
setTimeout(
(ctx) => {
ctx.connection.write(ctx.payload)
},
5000,
{ connection, payload },
)Refreshing timers
Pooled timers (delay >= 1000ms) support .refresh() to reset the delay without creating a new timer:
const handle = setTimeout(
() => {
console.log('idle timeout')
},
30000,
undefined,
)
// On activity, reset the timer. refresh() returns the same handle.
handle.refresh()
// Cancellation is terminal: refresh() does not reactivate a cleared handle.
clearTimeout(handle)API
setTimeout(callback, delay, opaque)
Schedule callback(opaque) after delay milliseconds.
- finite delays from
1000through2**31 - 1— use the pooled timer mechanism (500ms resolution) - all other delays — delegate to native
globalThis.setTimeoutfor Node's normalization and warnings
Returns a handle that can be passed to clearTimeout. Handles support .refresh() and [Symbol.dispose]().
clearTimeout(handle)
Cancel a pending timer. Works with both native and pooled handles. Passing null or undefined is a no-op. As with Node's native Timeout, cancellation is terminal.
How it works
All pooled timers share a native setTimeout handle that wakes every 500ms. Deadlines use elapsed monotonic time, and each pass fires timers whose deadlines fall within the next 250ms. Under normal event-loop scheduling, the quantisation error is therefore approximately ±250ms. After an event-loop stall, overdue timers fire on the first available driver callback instead of accumulating clock drift.
Pooling reduces native Timeout objects; it should not be interpreted as removing one libuv timer per JavaScript timeout, because Node already coalesces those internally.
Async context compatibility: Pooled handles intentionally do not create an AsyncResource. Their callbacks therefore do not preserve the per-timer AsyncLocalStorage context provided by native Timeout objects. Use globalThis.setTimeout directly when async-context propagation is required.
Note: Timers with the same delay are not guaranteed to fire in creation order. The execution order of same-delay timers is nondeterministic.
The internal timeout is unref()'d so it does not keep the process alive. Large fully-cancelled bursts are discarded immediately; smaller cancelled batches are pruned on the next driver pass.
Benchmark
yarn benchmarkTo reproduce the mixed-cancellation backing-store profile with forced garbage collection:
yarn benchmark:capacityResults on Apple M3 Pro / Node.js 26.5.0:
| Benchmark | native Timeout | pooled Timeout | pooled/native | | ---------------------------------------- | -------------: | -------------: | ------------: | | schedule + cancel | 198.43 ns | 41.59 ns | 0.21x | | schedule + cancel (forced GC) | 512.82 ns | 44.44 ns | 0.09x | | batch schedule + cancel 100x (forced GC) | 21.74 µs | 4.10 µs | 0.19x | | refresh pending timer | 28.95 ns | 33.03 ns | 1.14x |
Lower ratios are better. The schedule/cancel timings include the bounded immediate-cancellation path but exclude deferred dispatch pruning, which the harness drains between cases. A separate 100,000-entry profile, with one live timer keeping the pool active, measured cancelled-entry pruning at about 0.63ms; a fully-cancelled batch of at least 4096 entries bypasses that scan. In a matched 300,000-handle heap profile using the same callback and opaque argument, active pooled handles used about 74.5 bytes each versus 216.2 bytes for native handles.
Removing per-handle AsyncResource tracking produced the following paired results with the corrected benchmark teardown:
| Pooled operation | with AsyncResource | without AsyncResource | change |
| ----------------------------- | -------------------: | ----------------------: | -----: |
| schedule + cancel | 54.75 ns | 41.59 ns | -24% |
| schedule + cancel (forced GC) | 57.12 ns | 44.44 ns | -22% |
| batch schedule + cancel 100x | 5.39 µs | 4.10 µs | -24% |
| expire 10,000 timers | 0.383 ms | 0.281 ms | -27% |
| active handle heap usage | 130.6 B | 74.5 B | -43% |
The expiry result is the median across 40 measured samples after warm-up. Heap results exclude the preallocated holder array and use 300,000 simultaneously active handles.
License
MIT
