~/blog · 10 May 2026 · 6 min read
The silent scheduler bug: accumulated drift
I had a scheduler using setInterval that after a week in production was firing tasks several minutes late. Here's what I learned.
When I started building cron-scheduler-ts, my first instinct was to use setInterval. It's the obvious choice — you want something to run every X milliseconds, you use setInterval. The problem is that setInterval doesn't do exactly that, and the difference matters enormously in a scheduler.
What setInterval actually does
setInterval guarantees your callback will be called at least N milliseconds after the previous fire. It doesn't guarantee exactly N. If the handler takes 80ms to run, the next tick is delayed 80ms. If the event loop is busy when it's due, it's delayed further. Each fire accumulates a small error. After 1000 fires, that error can be minutes.
// ❌ Accumulates drift with every execution
// If the handler takes 50ms → next tick arrives 50ms late
// After 24h with per-minute fires → ~72 seconds of skew
setInterval(async () => {
await doWork(); // this takes variable time
}, 60_000);The fix: recompute from the clock, not from the last fire
The key is that every time a job fires, the scheduler doesn't say 'next fire in X ms'. It says 'next fire at the time calculated from now'. That means calling nextTick(expression, new Date()) after each execution. If the handler took 80ms, the next setTimeout's delay already accounts for it — it still points to the same second on the clock.
// ✅ No drift — delay is always calculated from new Date()
function scheduleJob(expr: string, handler: () => Promise<void>) {
const next = nextTick(expr, new Date()); // next absolute tick
const delay = next.getTime() - Date.now();
setTimeout(async () => {
await handler();
scheduleJob(expr, handler); // recompute from current clock
}, delay);
}drift-free cycle
What if the process was down?
This was the first question I got the day I published the package. If the server restarts at 3am and there was a job scheduled for 2:30, what happens? In cron-scheduler-ts, when the scheduler starts it reads the persisted state of each job. If lastRun + interval is less than now, it knows that job was missed. It emits a missed event and reschedules for the next future tick. It doesn't replay — it just tells you so you can decide what to do.
const scheduler = new Scheduler({
storage: new FileSystemAdapter("./state.json"),
});
// The scheduler tells you — you decide whether to compensate or ignore
scheduler.on("missed", ({ id, name, scheduledFor }) => {
console.warn(`Job "${name}" didn't run at ${scheduledFor.toISOString()}`);
});
await scheduler.start();