~/blog · 10 de mayo de 2026 · 6 min de lectura
El bug silencioso de los schedulers: drift acumulado
Tenía un scheduler con setInterval que después de una semana en producción disparaba las tareas con varios minutos de retraso. Aquí está lo que aprendí.
Cuando empecé a construir cron-scheduler-ts, mi primer instinto fue usar setInterval. Es lo más obvio — quieres que algo se ejecute cada X milisegundos, usas setInterval. El problema es que setInterval no hace eso exactamente, y la diferencia importa muchísimo en un scheduler.
Qué hace setInterval realmente
setInterval garantiza que tu callback se llamará como mínimo N milisegundos después del disparo anterior. No garantiza exactamente N. Si el handler tarda 80ms en ejecutarse, el siguiente tick se retrasa 80ms. Si el event loop está ocupado cuando le toca, se retrasa más. Cada disparo acumula un pequeño error. Después de 1000 disparos, ese error puede ser minutos.
// ❌ Acumula drift con cada ejecución
// Si el handler tarda 50ms → el próximo tick llega 50ms tarde
// Tras 24h con disparos cada minuto → ~72 segundos de desfase
setInterval(async () => {
await hacerTrabajo(); // esto tarda tiempo variable
}, 60_000);La solución: recomputar desde el reloj, no desde el disparo
La clave es que cada vez que un job se dispara, el scheduler no dice 'próximo disparo en X ms'. Dice 'próximo disparo en el momento calculado desde ahora'. Eso significa llamar a nextTick(expresión, new Date()) después de cada ejecución. Si el handler tardó 80ms, el delay del siguiente setTimeout ya lo tiene en cuenta — sigue apuntando al mismo segundo en la hora.
// ✅ Sin drift — el delay se calcula siempre desde new Date()
function programarJob(expr: string, handler: () => Promise<void>) {
const siguiente = nextTick(expr, new Date()); // siguiente tick absoluto
const delay = siguiente.getTime() - Date.now();
setTimeout(async () => {
await handler();
programarJob(expr, handler); // recomputar desde el reloj actual
}, delay);
}ciclo sin drift
¿Y si el proceso estaba caído?
Esto me lo preguntaron el primer día que publiqué el paquete. Si el servidor se reinicia a las 3 de la mañana y había un job programado para las 2:30, ¿qué pasa? En cron-scheduler-ts, cuando el scheduler arranca lee el estado persistido de cada job. Si lastRun + intervalo es menor que ahora, sabe que ese job se perdió. Emite un evento missed y lo reprograma para el siguiente tick futuro. No lo reproduce — solo te avisa para que decidas qué hacer.
const scheduler = new Scheduler({
storage: new FileSystemAdapter("./estado.json"),
});
// El scheduler te avisa — tú decides si compensar o ignorar
scheduler.on("missed", ({ id, name, scheduledFor }) => {
console.warn(`Job "${name}" no se ejecutó a las ${scheduledFor.toISOString()}`);
});
await scheduler.start();