At some point every Node.js developer writes this:

setTimeout(() => sendReminder(userId), delayMs); 

It works. It's one line. And sooner or later someone asks: "how many of these can we have in flight at once?" The honest answer is "it depends on your machine," so rather than quoting numbers, this post gives you a small benchmark to produce your own, and explains how to read the results.

I won't share figures from my laptop. They'd be meaningless for your server, Node version and workload. Run it yourself, it takes a few minutes.

What we're measuring

Three things:

  1. Memory per pending timer. How much heap does each scheduled callback cost?
  2. Lateness. How far after the intended time does each callback actually fire, and how does that change as the number of timers grows?
  3. Event-loop impact. Does having lots of pending timers slow down other work?

And one thing we can't measure but must discuss: what happens to all of those timers when the process restarts.

The benchmark script

Save this as timers-bench.js:

// node --expose-gc timers-bench.js 100000 const N = Number(process.argv[2] || 100000); const SPREAD_MS = 30_000; // timers fire over the next 30 seconds const { monitorEventLoopDelay } = require("node:perf_hooks"); function heapMB() { global.gc && global.gc(); return process.memoryUsage().heapUsed / 1024 / 1024; } const before = heapMB(); const lateness = new Float64Array(N); let fired = 0; const start = performance.now(); for (let i = 0; i < N; i++) { const delay = Math.random() * SPREAD_MS; const due = start + delay; const payload = { id: i, userId: `user-${i}` }; // a realistic small closure setTimeout(() => { lateness[fired++] = performance.now() - due; if (payload.id === -1) console.log("never"); if (fired === N) report(); }, delay); } const after = heapMB(); console.log(`timers: ${N}`); console.log(`heap before: ${before.toFixed(1)} MB, after scheduling: ${after.toFixed(1)} MB`); console.log(`approx bytes per timer: ${(((after - before) * 1024 * 1024) / N).toFixed(0)}`); const loop = monitorEventLoopDelay({ resolution: 10 }); loop.enable(); function pct(arr, p) { const s = Array.from(arr).sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; } function report() { loop.disable(); console.log(`lateness p50: ${pct(lateness, 50).toFixed(1)} ms`); console.log(`lateness p99: ${pct(lateness, 99).toFixed(1)} ms`); console.log(`lateness max: ${lateness.reduce((m, x) => Math.max(m, x), 0).toFixed(1)} ms`); console.log(`event loop delay p99: ${(loop.percentile(99) / 1e6).toFixed(1)} ms`); } 

Run it at a few sizes:

for n in 10000 100000 1000000; do node --expose-gc timers-bench.js $n; echo; done 

--expose-gc lets the script force garbage collection before measuring, so the heap numbers are less noisy. They're still approximate.

Making it realistic

The bare benchmark measures timers in a quiet process. Your real process is doing other things. Two variations worth trying:

Add background load. Simulate request handling with some CPU work on an interval:

setInterval(() => { const end = performance.now() + 5; // 5ms of busy work while (performance.now() < end) {} }, 20); 

Now lateness reflects how timers behave when the event loop is busy, which is the situation you'll actually be in.

Make the payloads realistic. If your real callbacks close over a user object, a template and a database connection reference, the memory cost per timer goes up. Put in a payload shaped like your real one.

How to read the results

Memory. Multiply bytes-per-timer by the number of pending jobs you expect at your busiest. Then remember that this is per process. If you run four instances, each may hold its own copy of timers it created.

Lateness p50 vs p99. Median lateness for timers is usually small. The tail is what matters. If your p99 jumps sharply when you add background load, timers are competing with your real work, and anything time-sensitive will feel it.

Event-loop delay. If the event-loop delay rises as N grows, the timers themselves are costing you responsiveness. Firing many callbacks close together, at the same scheduled moment for example, is a common cause.

Long delays. Node's setTimeout has a maximum delay of about 24.8 days (2³¹−1 milliseconds). Longer values don't wait longer: Node warns and fires the callback after roughly 1 ms instead. Try it with a big delay and see. If you're scheduling anything weeks out, timers are the wrong tool regardless of what the benchmark says.

The result no benchmark shows

Here's the important part. Suppose the benchmark tells you a million timers fit comfortably in memory and fire on time. Then you deploy.

During a deploy, the process restarts. Every pending timer is gone. There's no error and no log line. Reminders that were due in the next hour simply never happen. The same applies to crashes, out-of-memory kills, autoscaling removing an instance, and a host rebooting for patches.

That's why the question "how many timers can Node hold?" is usually the wrong one. The more useful question is "what happens to pending work when this process dies?" For in-process timers the answer is always "it's lost", whatever the count.

The usual fix is to store the job, with its due time, somewhere durable: a database table, a queue with delayed delivery, or a scheduling service. A worker picks up due jobs and runs them. In-process timers can still be useful for short-lived, losable work: debouncing, retrying a request a few seconds later, or waking a worker up early. They're a poor home for anything a customer is waiting on.

A fair comparison to run next

If you want to compare, try the same experiment with jobs stored in a table and a worker polling for due jobs every second. You'll typically see:

  • Lateness roughly bounded by the polling interval, plus query time.
  • Memory that no longer grows with pending jobs.
  • Restarts that lose nothing.

Whether that trade-off is worth it depends on whether a lost job matters. For reminders, invoices and anything with a promise attached, it usually does.

Durable "run this later" is the problem we work on at delay.run, but the experiment above is worth running whatever you end up using.

Related reading

How many pending in-process timers does your busiest service hold right now, and do you know what happens to them on deploy?