Programar trabajos en segundo plano sin los problemas de cron
Aprende patrones para programar trabajos en segundo plano usando workflows y una tabla de jobs para ejecutar recordatorios, resúmenes diarios y limpieza de forma fiable.

Por qué cron parece simple hasta que deja de serlo
Cron es estupendo el primer día: escribes una línea, eliges una hora y lo olvidas. Para un servidor y una tarea suele funcionar.
Los problemas aparecen cuando dependes de la programación para comportamiento real del producto: recordatorios, resúmenes diarios, limpieza o sincronizaciones. La mayoría de las historias de “ejecuciones perdidas” no son culpa de cron. Son todo lo que lo rodea: un reinicio del servidor, un deploy que sobrescribió el crontab, una tarea que duró más de lo esperado o una desincronización de reloj o zona horaria. Y cuando empiezas a ejecutar varias instancias de la app, aparece el fallo opuesto: duplicados, porque dos máquinas creen que deben ejecutar la misma tarea.
Las pruebas son otro punto débil. Una línea de cron no te da una manera limpia de ejecutar “¿qué pasaría a las 9:00 AM mañana?” en una prueba repetible. Entonces la programación se convierte en comprobaciones manuales, sorpresas en producción y caza de logs.
Antes de elegir un enfoque, sé claro sobre qué estás programando. La mayoría del trabajo en segundo plano cae en unos pocos grupos:
- Recordatorios (enviar en un momento específico, solo una vez)
- Resúmenes diarios (agregar datos y luego enviar)
- Tareas de limpieza (borrar, archivar, expirar)
- Sincronizaciones periódicas (traer o empujar actualizaciones)
A veces puedes prescindir de la programación por completo. Si algo puede ocurrir justo cuando sucede un evento (un usuario se registra, un pago se completa, un ticket cambia de estado), el trabajo dirigido por eventos suele ser más simple y fiable que el trabajo dirigido por tiempo.
Cuando necesitas tiempo, la fiabilidad se reduce básicamente a visibilidad y control. Quieres un lugar para registrar qué debe ejecutarse, qué se ejecutó y qué falló, además de una manera segura de reintentar sin crear duplicados.
El patrón básico: scheduler, tabla de jobs, worker
Una forma sencilla de evitar dolores de cabeza con cron es dividir responsabilidades:
- Un scheduler decide qué debe ejecutarse y cuándo.
- Un worker hace el trabajo.
Mantener esos roles separados ayuda de dos formas. Puedes cambiar el timing sin tocar la lógica del negocio, y puedes cambiar la lógica del negocio sin romper la programación.
Una tabla de jobs se convierte en la fuente de la verdad. En lugar de esconder estado dentro de un proceso de servidor o en una línea de cron, cada unidad de trabajo es una fila: qué hacer, para quién, cuándo debe ejecutarse y qué pasó la última vez. Cuando algo falla, puedes inspeccionarlo, reintentarlo o cancelarlo sin adivinar.
Un flujo típico se ve así:
- El scheduler escanea jobs elegibles (por ejemplo,
run_at <= nowystatus = queued). - Reclama un job para que solo un worker lo tome.
- Un worker lee los detalles del job y realiza la acción.
- El worker registra el resultado de vuelta en la misma fila.
La idea clave es hacer el trabajo reanudable, no mágico. Si un worker falla a mitad de camino, la fila del job todavía debe decirte qué pasó y qué hacer después.
Diseñar una tabla de jobs que siga siendo útil
Una tabla de jobs debe responder rápidamente a dos preguntas: qué necesita ejecutarse a continuación y qué pasó la última vez.
Empieza con un conjunto pequeño de campos que cubran identidad, tiempo y progreso:
- id, type: un id único más un tipo corto como
send_reminderodaily_summary. - payload: JSON validado con solo lo que el worker necesita (por ejemplo
user_id, no el objeto completo del usuario). - run_at: cuándo el job se vuelve elegible para ejecutarse.
- status:
queued,running,succeeded,failed,canceled. - attempts: incrementado en cada intento.
Luego añade algunas columnas operativas que hacen la concurrencia segura y los incidentes más fáciles de manejar. locked_at, locked_by y locked_until permiten que un worker reclame un job para que no se ejecute dos veces. last_error debe ser un mensaje corto (y opcionalmente un código de error), no un volcado de stack trace que hinche las filas.
Finalmente, guarda timestamps que ayuden tanto a soporte como a reporting: created_at, updated_at y finished_at. Estos te permiten responder preguntas como “¿Cuántos recordatorios fallaron hoy?” sin hurgar en logs.
Los índices importan porque tu sistema constantemente pregunta “¿qué sigue?”. Dos que suelen justificar su coste:
(status, run_at)para obtener jobs elegibles rápidamente(type, status)para inspeccionar o pausar una familia de jobs durante incidentes
Para los payloads, prefiere JSON pequeño y focalizado y valídalo antes de insertar el job. Almacena identificadores y parámetros, no snapshots de datos de negocio. Trata la forma del payload como un contrato de API para que jobs antiguos encolados sigan funcionando después de cambios en la app.
Ciclo de vida del job: estados, locking e idempotencia
Un job runner se mantiene fiable cuando cada job sigue un ciclo de vida pequeño y predecible. Ese ciclo es tu red de seguridad cuando dos workers arrancan a la vez, un servidor se reinicia a mitad de ejecución o necesitas reintentar sin crear duplicados.
Una máquina de estados simple suele ser suficiente:
- queued: listo para ejecutarse a o después de
run_at - running: reclamado por un worker
- succeeded: terminado y no debe ejecutarse otra vez
- failed: terminado con error y necesita atención
- canceled: detenido intencionalmente (por ejemplo, el usuario se dio de baja)
Reclamar jobs sin trabajo duplicado
Para evitar duplicados, reclamar un job debe ser atómico. El enfoque común es un lock con timeout (una lease): un worker reclama un job poniendo status=running y escribiendo locked_by y locked_until. Si el worker falla, el lock expira y otro worker puede reclamarlo.
Un conjunto práctico de reglas para reclamar:
- reclamar solo jobs queued cuyo
run_at <= now - establecer
status,locked_byylocked_untilen la misma actualización - volver a reclamar jobs running solo cuando
locked_until < now - mantener la lease corta y extenderla si el job es largo
Idempotencia (el hábito que te salva)
Idempotencia significa: si el mismo job se ejecuta dos veces, el resultado sigue siendo correcto.
La herramienta más simple es una clave única. Por ejemplo, para un resumen diario puedes forzar un job por usuario y por día con una clave como summary:user123:2026-01-25. Si ocurre una inserción duplicada, apunta al mismo job en lugar de crear uno nuevo.
Marca éxito solo cuando el efecto secundario esté realmente hecho (email enviado, registro actualizado). Si reintentas, la vía de reintento no debe crear un segundo correo ni una escritura duplicada.
Reintentos y manejo de fallos sin drama
Los reintentos son donde los sistemas de jobs o se vuelven confiables o se convierten en ruido. La meta es sencilla: reintentar cuando la falla es probablemente temporal, parar cuando no lo es.
Una política de reintento por defecto suele incluir:
- intentos máximos (por ejemplo, 5 intentos en total)
- estrategia de retraso (delay fijo o backoff exponencial)
- condiciones de parada (no reintentar errores de “input inválido”)
- jitter (un pequeño offset aleatorio para evitar picos de reintentos)
En lugar de inventar un nuevo estado para reintentos, a menudo puedes reutilizar queued: ajusta run_at al momento del siguiente intento y vuelve a poner el job en la cola. Eso mantiene pequeña la máquina de estados.
Cuando un job puede hacer progreso parcial, trátalo como normal. Guarda un checkpoint para que un reintento pueda continuar con seguridad, ya sea en el payload del job (como last_processed_id) o en una tabla relacionada.
Ejemplo: un job de resumen diario genera mensajes para 500 usuarios. Si falla en el usuario 320, guarda el último id procesado con éxito y reinténtalo desde el 321. Si además guardas un registro summary_sent por usuario y día, una nueva ejecución puede saltarse los usuarios ya procesados.
Logging que realmente ayude
Loggea lo suficiente para depurar en minutos:
- id del job, tipo y número de intento
- entradas clave (user/team id, rango de fechas)
- tiempos (started_at, finished_at, next run time)
- resumen corto del error (más stack trace si lo tienes)
- conteo de efectos secundarios (emails enviados, filas actualizadas)
Paso a paso: construir un loop de scheduler simple
Un loop de scheduler es un proceso pequeño que despierta con una cadencia fija, busca trabajo elegible y lo entrega. El objetivo es una fiabilidad aburrida, no sincronización perfecta. Para muchas apps, “despertar cada minuto” es suficiente.
Elige la frecuencia según qué tan sensibles al tiempo sean los jobs y cuánto carga puede soportar tu base de datos. Si los recordatorios deben ser casi en tiempo real, ejecuta cada 30 a 60 segundos. Si los resúmenes diarios pueden desviarse un poco, cada 5 minutos está bien y es más barato.
Un loop sencillo:
- Despierta y toma la hora actual (usa UTC).
- Selecciona jobs elegibles donde
status = 'queued'yrun_at <= now. - Reclama jobs de forma segura para que solo un worker los tome.
- Entrega cada job reclamado a un worker.
- Duerme hasta el siguiente tick.
El paso de reclamar es donde muchos sistemas fallan. Quieres marcar un job como running (y guardar locked_by y locked_until) en la misma transacción que lo selecciona. Muchas bases de datos soportan lecturas “skip locked” para que múltiples schedulers puedan ejecutarse sin pisarse.
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
Mantén el tamaño de lote pequeño (como 50 a 200). Lotes más grandes pueden ralentizar la base de datos y hacer que los fallos sean más dolorosos.
Si el scheduler falla a mitad de lote, la lease te salva. Los jobs atascados en running vuelven a ser elegibles después de locked_until. Tu worker debe ser idempotente para que un job reclamado de nuevo no cree emails duplicados ni cargos dobles.
Patrones para recordatorios, resúmenes diarios y limpieza
La mayoría de equipos termina con tres tipos de trabajo en segundo plano: mensajes que deben enviarse a tiempo, informes que se ejecutan en un horario y limpieza que mantiene el almacenamiento y el rendimiento saludables. La misma tabla de jobs y el loop de workers pueden manejarlos todos.
Recordatorios
Para recordatorios, almacena todo lo necesario para enviar el mensaje en la fila del job: a quién va dirigido, qué canal (email, SMS, Telegram, in-app), qué plantilla y la hora exacta de envío. El worker debe poder ejecutar el job sin “buscar” contexto adicional.
Si muchos recordatorios vencen al mismo tiempo, añade limitación de tasa. Limita mensajes por minuto por canal y deja que los jobs extra esperen la siguiente ejecución.
Resúmenes diarios
Los resúmenes diarios fallan cuando la ventana de tiempo es difusa. Elige un cutoff estable (por ejemplo, 08:00 en la hora local del usuario) y define la ventana claramente (por ejemplo, “ayer 08:00 a hoy 08:00”). Almacena el cutoff y la zona horaria del usuario con el job para que las re-ejecuciones produzcan el mismo resultado.
Mantén cada job de resumen pequeño. Si necesita procesar miles de registros, divídelo en trozos (por equipo, por cuenta o por rango de ID) y encola jobs de seguimiento.
Tareas de limpieza
La limpieza es más segura cuando separas “borrar” de “archivar”. Decide qué puede eliminarse para siempre (tokens temporales, sesiones expiradas) y qué debe archivarse (logs de auditoría, facturas). Ejecuta la limpieza en lotes previsibles para evitar locks largos y picos de carga repentinos.
Tiempo y zonas horarias: la fuente oculta de bugs
Muchos fallos son bugs de tiempo: un recordatorio sale una hora antes, un resumen diario se salta el lunes o la limpieza se ejecuta dos veces.
Un buen por defecto es almacenar timestamps de programación en UTC y guardar la zona horaria del usuario por separado. Tu run_at debe ser un momento UTC. Cuando un usuario dice “9:00 AM en mi zona”, conviértelo a UTC al programar.
El horario de verano es donde las configuraciones ingenuas fallan. “Cada día a las 9:00 AM” no es lo mismo que “cada 24 horas”. En los cambios de DST, las 9:00 AM se mapean a diferentes instantes UTC, y algunas horas locales no existen (spring forward) o ocurren dos veces (fall back). El enfoque más seguro es calcular la próxima ocurrencia local cada vez que reprogramas y luego convertirla a UTC de nuevo.
Para un resumen diario, decide qué significa “un día” antes de escribir código. Un día de calendario (medianoche a medianoche en la zona horaria del usuario) coincide con las expectativas humanas. “Últimas 24 horas” es más simple pero deriva y sorprende a las personas.
Los datos tardíos son inevitables: un evento llega tras un reintento o se añade una nota unos minutos después de medianoche. Decide si los eventos tardíos pertenecen a “ayer” (con un periodo de gracia) o a “hoy”, y mantén esa regla consistente.
Un buffer práctico puede prevenir faltas:
- escanea jobs vencidos hasta 2 a 5 minutos atrás
- haz el job idempotente para que las re-ejecuciones sean seguras
- registra el rango de tiempo cubierto en el payload para que los resúmenes sean consistentes
Errores comunes que causan ejecuciones perdidas o duplicadas
La mayor parte del dolor viene de unas pocas suposiciones previsibles.
La mayor es asumir ejecución “exactamente una vez”. En sistemas reales, los workers se reinician, las llamadas de red timeoutean y los locks se pueden perder. Normalmente obtienes “at least once” delivery, lo que significa que los duplicados son normales y tu código debe tolerarlos.
Otro error es ejecutar efectos primero (enviar email, cobrar tarjeta) sin una comprobación de deduplicación. Una simple barrera suele resolverlo: un timestamp sent_at, una clave única como (user_id, reminder_type, date) o un token de dedupe almacenado.
La visibilidad es la siguiente brecha. Si no puedes responder “¿qué está atascado, desde cuándo y por qué?”, acabarás adivinando. Los datos mínimos para tener a mano son estado, contador de intentos, siguiente hora programada, último error y id del worker.
Los errores que más aparecen:
- diseñar jobs como si se ejecutaran exactamente una vez y luego sorprenderse por duplicados
- realizar efectos secundarios sin una comprobación de dedupe
- ejecutar un job enorme que intenta hacerlo todo y llega a timeouts a mitad de camino
- reintentar indefinidamente sin un límite
- no tener visibilidad de la cola (sin vista clara de backlog, fallos o jobs de larga ejecución)
Un ejemplo concreto: un job de resumen diario itera 50.000 usuarios y timeoutea en el usuario 20.000. Al reintentarlo, empieza de nuevo y vuelve a enviar resúmenes a los primeros 20.000 a menos que rastrees la finalización por usuario o lo dividas en jobs por usuario.
Lista rápida de comprobación para un sistema de jobs fiable
Un job runner solo está “terminado” cuando puedes confiar en él a las 2 a.m.
Asegúrate de tener:
- Visibilidad de la cola: contadores de queued vs running vs failed, más el job queued más antiguo.
- Idempotencia por defecto: asume que cada job puede ejecutarse dos veces; usa claves únicas o marcadores de “ya procesado”.
- Política de reintentos por tipo de job: reintentos, backoff y una condición clara de parada.
- Almacenamiento de tiempo consistente: guarda
run_aten UTC; convierte solo en la entrada y en la visualización. - Locks recuperables: una lease para que los crashes no dejen jobs en ejecución para siempre.
También limita batch size (cuántos jobs reclamas a la vez) y concurrencia de workers (cuántos corren simultáneamente). Sin límites, un pico puede sobrecargar tu base de datos o dejar sin recursos a otros trabajos.
Un ejemplo realista: recordatorios y resúmenes para un equipo pequeño
Una pequeña SaaS tiene 30 cuentas de cliente. Cada cuenta quiere dos cosas: un recordatorio a las 9:00 AM para tareas abiertas y un resumen diario a las 6:00 PM de lo que cambió ese día. También necesitan limpieza semanal para que la DB no se llene de logs antiguos y tokens expirados.
Usan una tabla de jobs más un worker que hace polling por jobs vencidos. Cuando un nuevo cliente se registra, el backend programa las primeras ejecuciones de recordatorios y resúmenes en función de la zona horaria del cliente.
Los jobs se crean en unos cuantos momentos comunes: al registrar (crear horarios recurrentes), en ciertos eventos (enfilar notificaciones puntuales), en un tick del scheduler (insertar próximas ejecuciones) y en el día de mantenimiento (encolar limpieza).
Un martes, el proveedor de email tiene una caída temporal a las 8:59 AM. El worker intenta enviar recordatorios, recibe un timeout y reprograma esos jobs ajustando run_at usando backoff (por ejemplo, 2 minutos, luego 10, luego 30), incrementando attempts cada vez. Como cada job de recordatorio tiene una clave de idempotencia como account_id + date + job_type, los reintentos no producen duplicados si el proveedor se recupera a mitad de vuelo.
La limpieza corre semanalmente en pequeños lotes para no bloquear otros trabajos. En lugar de borrar un millón de filas en un job, borra hasta N filas por ejecución y se reprograma a sí misma hasta terminar.
Cuando un cliente se queja “No recibí mi resumen”, el equipo revisa la tabla de jobs para esa cuenta y día: el estado del job, el contador de intentos, los campos de lock actuales y el último error devuelto por el proveedor. Eso convierte “se suponía que se envió” en “esto es exactamente lo que pasó”.
Siguientes pasos: implementar, observar y luego escalar
Elige un tipo de job y constrúyelo de extremo a extremo antes de añadir más. Un job de recordatorio único es un buen inicio porque toca todo: programación, reclamar trabajo vencido, enviar un mensaje y registrar resultados.
Empieza con una versión en la que confíes:
- crea la tabla de jobs y un worker que procese un tipo de job
- añade un loop de scheduler que reclame y ejecute jobs vencidos
- guarda suficiente payload para ejecutar el job sin conjeturas
- registra cada intento y resultado para que “¿Se ejecutó?” sea una pregunta de 10 segundos
- añade una ruta manual para re-ejecutar jobs fallidos para que la recuperación no requiera un deploy
Una vez que funcione, hazlo observable para humanos. Incluso una vista de admin básica paga rápido: busca jobs por estado, filtra por tiempo, inspecciona payload, cancela un job atascado, reejecuta un id de job específico.
Si prefieres modelar este tipo de scheduler y flujo de workers con lógica visual, AppMaster (appmaster.io) puede modelar la tabla de jobs en PostgreSQL e implementar el loop claim-process-update como un Business Process, al tiempo que genera código fuente real para despliegue.


