12 ago 2025·8 min de lectura

Disparadores vs trabajadores en segundo plano para notificaciones fiables

Aprende cuándo los disparadores o los trabajadores en segundo plano son más seguros para las notificaciones, con guía práctica sobre reintentos, transacciones y prevención de duplicados.

Disparadores vs trabajadores en segundo plano para notificaciones fiables

Por qué falla la entrega de notificaciones en apps reales

Las notificaciones parecen simples: un usuario hace algo y luego se envía un email o SMS. La mayoría de fallos reales se deben al momento y a la duplicación. Los mensajes se envían antes de que los datos estén realmente guardados, o se envían dos veces tras una falla parcial.

Una “notificación” puede ser muchas cosas: recibos por email, códigos SMS de un solo uso, alertas push, mensajes dentro de la app, pings a Slack o Telegram, o un webhook a otro sistema. El problema compartido es siempre el mismo: intentas coordinar un cambio en la base de datos con algo fuera de tu aplicación.

El mundo exterior es desordenado. Los proveedores pueden ir lentos, devolver timeouts o aceptar una petición mientras tu app nunca recibe la respuesta de éxito. Tu propia app puede caerse o reiniciarse a mitad de la petición. Incluso los envíos “exitosos” pueden volver a ejecutarse por reintentos de infraestructura, reinicios de workers o por un usuario que pulsa el botón otra vez.

Las causas comunes de entrega rota incluyen timeouts de red, caídas o límites de los proveedores, reinicios de la app en el momento equivocado, reintentos que vuelven a ejecutar la misma lógica sin una protección única, y diseños donde una escritura en la base de datos y un envío externo suceden como un único paso combinado.

Cuando la gente pide “notificaciones fiables”, normalmente quiere una de dos cosas:

  • entregar exactamente una vez, o
  • al menos nunca duplicar (los duplicados a menudo son peores que un retraso).

Conseguir rapidez y seguridad perfecta es difícil, así que terminas eligiendo compensaciones entre velocidad, seguridad y complejidad.

Por eso la elección entre triggers y trabajadores en segundo plano no es solo un debate arquitectónico. Se trata de cuándo se permite enviar, cómo se reintentan los fallos y cómo evitas duplicar emails o SMS cuando algo sale mal.

Triggers y workers: qué significan

Cuando la gente compara triggers con trabajadores en segundo plano, realmente compara dónde corre la lógica de notificación y qué tan estrechamente está ligada a la acción que la causó.

Un trigger es “hazlo ahora cuando X sucede”. En muchas apps eso significa enviar un email o SMS justo después de una acción de usuario, dentro de la misma petición web. Los triggers también pueden vivir a nivel de base de datos: un trigger de BD se ejecuta automáticamente cuando se inserta o actualiza una fila. Ambos tipos se sienten inmediatos, pero heredan el tiempo y los límites de lo que los disparó.

Un trabajador en segundo plano es “hazlo pronto, pero no en primer plano”. Es un proceso separado que extrae jobs de una cola y trata de completarlos. Tu app principal registra lo que debe pasar y luego devuelve rápido, mientras el worker maneja las partes más lentas y propensas a fallos como llamar a un proveedor de email o SMS.

Un “job” es la unidad de trabajo que procesa el worker. Normalmente incluye a quién notificar, qué plantilla, qué datos rellenar, el estado actual (queued, processing, sent, failed), cuántos intentos han ocurrido y, a veces, una hora programada.

Un flujo típico de notificación se ve así: preparas los detalles del mensaje, pones un job en la cola, envías a través de un proveedor, registras el resultado y luego decides si reintentar, detenerte o alertar a alguien.

Límites de transacción: cuándo es realmente seguro enviar

Un límite de transacción es la línea entre “intentamos guardarlo” y “está realmente guardado”. Hasta que la base de datos haga commit, el cambio aún puede revertirse. Eso importa porque las notificaciones son difíciles de deshacer.

Si envías un email o SMS antes del commit, puedes notificar a alguien sobre algo que nunca ocurrió. Un cliente puede recibir “Tu contraseña fue cambiada” o “Tu pedido está confirmado” y luego la escritura falle por una violación de constraint o un timeout. Ahora el usuario está confundido y soporte tiene que desenredarlo.

Enviar desde dentro de un trigger de base de datos resulta tentador porque se ejecuta automáticamente cuando cambian los datos. El problema es que los triggers corren dentro de la misma transacción. Si la transacción se revierte, puede que ya hayas llamado al proveedor de email o SMS.

Los triggers de BD además tienden a ser más difíciles de observar, probar y reintentar de forma segura. Y cuando realizan llamadas externas lentas, pueden mantener locks más tiempo del esperado y empeorar problemas de la base de datos.

Un enfoque más seguro es la idea del outbox: registra la intención de notificar como datos, haz commit, y luego envía.

Haces el cambio de negocio y, en la misma transacción, insertas una fila en el outbox que describe el mensaje (a quién, qué, qué canal, además de una clave única). Tras el commit, un trabajador en segundo plano lee las filas pendientes del outbox, envía el mensaje y las marca como enviadas.

Los envíos inmediatos aún pueden estar bien para mensajes informativos de bajo impacto donde equivocarse es aceptable, como “Estamos procesando tu solicitud”. Para cualquier cosa que deba coincidir con el estado final, espera hasta después del commit.

Reintentos y manejo de fallos: dónde gana cada enfoque

Los reintentos suelen ser el factor decisivo.

Triggers: rápidos, pero frágiles ante fallos

La mayoría de diseños basados en triggers no tienen una buena historia de reintentos.

Si un trigger llama a un proveedor y la llamada falla, por lo general te quedan dos malas opciones:

  • fallar la transacción (y bloquear la actualización original), o
  • ignorar el error (y perder silenciosamente la notificación).

Ninguna es aceptable cuando la fiabilidad importa.

Intentar iterar o retrasar dentro de un trigger puede empeorar las cosas al mantener transacciones abiertas más tiempo, aumentar el tiempo de lock y ralentizar la base de datos. Y si la BD o la app mueren a mitad del envío, a menudo no puedes saber si el proveedor recibió la petición.

Workers en segundo plano: diseñados para reintentos

Un worker trata el envío como una tarea separada con su propio estado. Eso hace natural reintentar solo cuando tiene sentido.

Como regla práctica, reintentas fallos temporales (timeouts, problemas transitorios de red, errores server, límites de tasa con espera más larga). Normalmente no reintentas problemas permanentes (números inválidos, emails mal formados, rechazos duros como usuarios desuscritos). Para errores “desconocidos”, limitas los intentos y haces el estado visible.

El backoff es lo que evita que los reintentos empeoren las cosas. Empieza con una espera corta y aumenta cada vez (por ejemplo 10s, 30s, 2m, 10m) y para tras un número fijo de intentos.

Para que esto sobreviva a despliegues y reinicios, guarda el estado de reintentos con cada job: contador de intentos, próxima hora de intento, último error (corto y legible), última hora de intento y un estado claro como pending, sending, sent, failed.

Si tu app se reinicia a mitad de un envío, un worker puede volver a comprobar jobs atascados (por ejemplo estado = sending con un timestamp antiguo) y reintentarlos de forma segura. Aquí la idempotencia es esencial para que un reintento no envíe doble.

Evitar duplicados con idempotencia

Haz los envíos observables
Modela trabajos de notificación en PostgreSQL y mantén visibles reintentos y estados.
Comenzar a crear

Idempotencia significa que puedes ejecutar la misma acción de “enviar notificación” más de una vez y el usuario la reciba solo una vez.

El caso clásico de duplicación es un timeout: tu app llama al proveedor, la petición agota el tiempo y tu código reintenta. La primera petición puede haber tenido éxito realmente, así que el reintento crea un duplicado.

Una solución práctica es dar a cada mensaje una clave estable y tratar esa clave como la fuente única de la verdad. Buenas claves describen lo que significa el mensaje, no cuándo intentaste enviarlo.

Enfoques comunes incluyen:

  • un notification_id generado cuando decides “este mensaje debe existir”, o
  • una clave derivada del negocio como order_id + template + recipient (solo si eso realmente define la unicidad).

Luego guarda un ledger de envíos (a menudo la propia tabla outbox) y haz que todos los reintentos lo consulten antes de enviar. Mantén los estados simples y visibles: created (decidido), queued (listo), sent (confirmado), failed (fallo confirmado), canceled (ya no es necesario). La regla crítica es permitir solo un registro activo por clave de idempotencia.

La idempotencia ofrecida por el proveedor puede ayudar cuando existe, pero no sustituye a tu propio ledger. Aún necesitas manejar reintentos, despliegues y reinicios de workers.

También trata los resultados “desconocidos” como de primera clase. Si una petición agotó el tiempo, no envíes de nuevo de inmediato. Márcala como pendiente de confirmación y reintenta de forma segura consultando el estado de entrega del proveedor cuando sea posible. Si no puedes confirmar, retrasa y alerta en vez de enviar doble.

Un patrón por defecto seguro: outbox + worker (paso a paso)

Si quieres un default seguro, el patrón outbox más un worker es difícil de superar. Mantiene el envío fuera de tu transacción de negocio, a la vez que garantiza que la intención de notificar está guardada.

El flujo

Trata “enviar una notificación” como datos que guardas, no como una acción que disparas.

Guardas el cambio de negocio (por ejemplo, el estado de un pedido). En la misma transacción también insertas una fila en el outbox con destinatario, canal (email/SMS), plantilla, payload y una clave de idempotencia. Haces commit de la transacción. Solo a partir de ese punto se puede enviar algo.

Un worker en segundo plano recoge regularmente las filas pendientes del outbox, las envía y registra el resultado.

Añade un paso simple de claim para que dos workers no agarren la misma fila. Esto puede ser un cambio de estado a processing o una marca temporal de lock.

Evitar duplicados y manejar fallos

Los duplicados suelen ocurrir cuando un envío tuvo éxito pero tu app se cayó antes de registrar “sent”. Lo solucionas haciendo que la escritura de “marcar enviado” sea segura para repetir.

Usa una regla de unicidad (por ejemplo, constraint único sobre la clave de idempotencia y el canal). Reintenta con reglas claras: intentos limitados, demoras crecientes y solo para errores reintentables. Tras el último reintento, mueve el job a un estado de dead-letter (como failed_permanent) para que alguien lo revise y lo reprocese manualmente.

La monitorización puede ser simple: contadores de pending, processing, sent, retrying y failed_permanent, además del timestamp del mensaje pendiente más antiguo.

Ejemplo concreto: cuando un pedido pasa de “Empaquetado” a “Enviado”, actualizas la fila del pedido y creas una sola fila en outbox con clave de idempotencia order-4815-shipped. Incluso si el worker falla a mitad del envío, las reejecuciones no duplicarán porque la escritura de “sent” está protegida por esa clave única.

Cuándo los workers son la mejor elección

Construye flujos listos para producción
Genera código real para backend y apps mientras mantienes la lógica de notificaciones consistente.
Construir con AppMaster

Los triggers de BD reaccionan en el momento en que cambian los datos. Pero si la tarea es “entregar una notificación de forma fiable en condiciones reales y desordenadas”, los workers suelen darte más control.

Los workers son mejores cuando necesitas envíos basados en tiempo (recordatorios, resúmenes), alto volumen con límites de tasa y backpressure, tolerancia a la variabilidad del proveedor (límite 429, respuestas lentas, cortas caídas), flujos multi-paso (enviar, esperar entrega y luego hacer seguimiento) o eventos entre sistemas que requieren reconciliación.

Ejemplo simple: cobras a un cliente, luego envías un recibo por SMS y después envías por email la factura. Si el SMS falla por un problema en el gateway, aún quieres que la orden quede pagada y que el reintento sea seguro más tarde. Poner esa lógica en un trigger mezcla “los datos están correctos” con “un tercero está disponible ahora mismo”.

Los workers también facilitan el control operativo. Puedes pausar una cola durante un incidente, inspeccionar fallos y reintentar con demoras.

Errores comunes que causan mensajes perdidos o duplicados

Mantén los triggers livianos
Configura email, SMS o Telegram sin mezclarlo dentro de triggers de base de datos.
Probar AppMaster

La forma más rápida de tener notificaciones poco fiables es “simplemente enviarlo” donde te resulte conveniente y esperar que los reintentos lo arreglen. Tanto si usas triggers como workers, los detalles de fallo y estado deciden si los usuarios reciben un mensaje, dos o ninguno.

Una trampa común es enviar desde un trigger de base de datos y asumir que no puede fallar. Los triggers se ejecutan dentro de la transacción, así que cualquier llamada lenta a un proveedor puede bloquear la escritura, provocar timeouts o bloquear tablas más tiempo del esperado. Peor aún, si el envío falla y rollbackeas la transacción, podrías reintentar después y enviar dos veces si el proveedor aceptó la primera llamada.

Errores que aparecen repetidamente:

  • Reintentar todo de la misma manera, incluyendo errores permanentes (email malo, número bloqueado).
  • No separar “queued” de “sent”, de modo que no sabes qué es seguro reintentar tras un crash.
  • Usar timestamps como claves de deduplicación, de forma que los reintentos inevitablemente evitan la unicidad.
  • Hacer llamadas a proveedores en el camino de la petición del usuario (checkout y submit no deberían esperar a gateways).
  • Tratar timeouts de proveedor como “no entregado”, cuando muchos son en realidad “desconocido”.

Un ejemplo simple: envías un SMS, el proveedor agota el tiempo y reintentas. Si la primera petición tuvo éxito, el usuario recibe dos códigos. La solución es registrar una clave de idempotencia estable (por ejemplo notification_id), marcar el mensaje como queued antes de enviar y solo marcarlo como sent tras una respuesta clara de éxito.

Comprobaciones rápidas antes de lanzar notificaciones

La mayoría de los bugs de notificaciones no están en la herramienta. Están en el tiempo, los reintentos y los registros faltantes.

Confirma que solo envías después de que la escritura en la base de datos se haya confirmado con éxito. Si envías dentro de la misma transacción y luego hace rollback, los usuarios pueden recibir un mensaje sobre algo que no ocurrió.

Luego, haz que cada notificación sea identificable de forma única. Da a cada mensaje una clave de idempotencia estable (por ejemplo order_id + event_type + channel) y aplícala en el almacenamiento para que un reintento no cree una segunda notificación “nueva”.

Antes del release, verifica estos básicos:

  • El envío ocurre tras el commit, no durante la escritura.
  • Cada notificación tiene una clave de idempotencia única y los duplicados son rechazados.
  • Los reintentos son seguros: el sistema puede ejecutar el mismo job otra vez y aun así enviar como máximo una vez.
  • Cada intento queda registrado (estado, last_error, timestamps).
  • Los intentos están limitados y los elementos atascados tienen un lugar claro para revisar y reprocesar.

Prueba el comportamiento de reinicio a propósito. Mata el worker a mitad de envío, reinícialo y verifica que no hay doble envío. Haz lo mismo con la base de datos bajo carga.

Un escenario simple para validar: un usuario cambia su número, luego envías un SMS de verificación. Si el proveedor de SMS hace timeout, tu app reintenta. Con una buena clave de idempotencia y un registro de intentos, o envías una vez o intentas de forma segura más tarde, pero no spameas.

Escenario de ejemplo: actualizaciones de pedidos sin doble envío

Da herramientas claras al soporte
Añade vistas de administración para revisar mensajes fallidos y reprocesarlos de forma segura.
Crear app

Una tienda envía dos tipos de mensajes: (1) un email de confirmación de pedido tras el pago, y (2) SMS de estado cuando el paquete está en reparto y cuando se entrega.

Esto es lo que falla cuando envías demasiado pronto (por ejemplo, dentro de un trigger): el paso de pago escribe una fila en orders, el trigger dispara y envía un email al cliente, y luego la captura del pago falla un segundo después. Ahora tienes un email de “Gracias por tu pedido” para un pedido que nunca se materializó.

Ahora imagina el problema opuesto: el estado de entrega cambia a “En reparto”, llamas al proveedor de SMS y el proveedor hace timeout. No sabes si envió el mensaje. Si reintentas inmediatamente, arriesgas dos SMS. Si no reintentas, arriesgas enviar ninguno.

Un flujo más seguro usa una fila outbox más un worker. La app hace commit del pedido o del cambio de estado y en la misma transacción escribe una fila outbox como “enviar plantilla X al usuario Y, canal SMS, clave de idempotencia Z”. Solo después del commit un worker entrega los mensajes.

Una línea de tiempo simple:

  • El pago tiene éxito, la transacción hace commit y se guarda la fila outbox para el email de confirmación.
  • El worker envía el email y marca el outbox como enviado con un ID del proveedor.
  • El estado de entrega cambia, la transacción hace commit y se guarda la fila outbox para la actualización por SMS.
  • El proveedor hace timeout, el worker marca el outbox como reintentable y lo intenta más tarde usando la misma clave de idempotencia.

En el reintento, la fila outbox es la fuente única de la verdad. No estás creando una segunda petición de envío; estás terminando la primera.

Para soporte esto también es más claro. Pueden ver mensajes atascados en “failed” con el último error (timeout, número malo, email bloqueado), cuántos intentos se hicieron y si es seguro reintentar sin enviar doble.

Próximos pasos: elige un patrón e implémentalo limpiamente

Elige un default y escríbelo. El comportamiento inconsistente suele venir de mezclar triggers y workers al azar.

Comienza pequeño con una tabla outbox y un bucle de worker. El primer objetivo no es la velocidad, es la corrección: guarda lo que tienes intención de enviar, envíalo después del commit y solo márcalo como enviado cuando el proveedor confirme.

Un plan de despliegue simple:

  • Define eventos (order_paid, ticket_assigned) y qué canales pueden usar.
  • Añade una tabla outbox con event_id, recipient, payload, status, attempts, next_retry_at, sent_at.
  • Construye un worker que consulte filas pendientes, envíe y actualice el estado en un solo lugar.
  • Añade idempotencia con una clave única por mensaje y “no hacer nada si ya está enviado”.
  • Clasifica errores en reintentables (timeouts, 5xx) vs no reintentables (número malo, email bloqueado).

Antes de escalar volumen, añade visibilidad básica. Mide el conteo pendiente, la tasa de fallos y la edad del mensaje pendiente más antiguo. Si el más antiguo sigue creciendo, probablemente tienes un worker atascado, una caída del proveedor o un bug lógico.

Si construyes en AppMaster (appmaster.io), este patrón encaja bien: modela el outbox en el Data Designer, escribe la actualización de negocio y la fila outbox en una transacción, y luego ejecuta la lógica de envío y reintentos en un proceso en segundo plano separado. Esa separación es lo que mantiene la entrega de notificaciones fiable incluso cuando los proveedores o los despliegues se comportan mal.

FAQ

¿Debería usar triggers o trabajadores en segundo plano para notificaciones?

Los trabajadores en segundo plano suelen ser la opción más segura porque el envío es lento y propenso a fallos; los workers están diseñados para reintentos y para ofrecer visibilidad. Los triggers pueden ser rápidos, pero están muy acoplados a la transacción o petición que los disparó, lo que complica manejar fallos y duplicados de forma limpia.

¿Por qué es arriesgado enviar una notificación antes del commit de la base de datos?

Es peligroso porque la escritura en la base de datos aún puede revertirse. Puedes notificar a alguien sobre un pedido, un cambio de contraseña o un pago que en realidad no se llegó a confirmar, y no hay forma práctica de "deshacer" un correo o SMS que ya salió.

¿Cuál es el mayor problema de enviar desde un trigger de base de datos?

Un trigger de base de datos se ejecuta dentro de la misma transacción que el cambio de fila. Si llama a un proveedor de email/SMS y después la transacción falla, puedes haber enviado un mensaje real sobre un cambio que no se guardó, o bien haber bloqueado la transacción por una llamada externa lenta.

¿Qué es el patrón outbox en términos sencillos?

El patrón outbox guarda la intención de enviar como una fila en la base de datos, en la misma transacción que el cambio de negocio. Tras el commit, un worker lee las filas pendientes, envía el mensaje y marca la fila como enviada, lo que hace que el momento del envío y los reintentos sean mucho más seguros.

¿Qué debo hacer cuando una petición al proveedor de email/SMS expira?

Cuando hay un timeout, el resultado real suele ser "desconocido", no "fallado". Un buen sistema registra el intento, espera y reintenta con seguridad usando la misma identidad del mensaje, en lugar de enviar inmediatamente de nuevo y arriesgar un duplicado.

¿Cómo evito duplicados de emails o SMS cuando ocurren reintentos?

Usa idempotencia: da a cada notificación una clave estable que represente lo que significa el mensaje (no cuándo intentaste enviarlo). Guarda esa clave en un ledger (a menudo la tabla outbox) y aplica una regla de un registro activo por clave, de modo que los reintentos completen el mismo envío en lugar de crear uno nuevo.

¿Qué errores debo reintentar y cuáles tratar como permanentes?

Reintenta errores temporales como timeouts, respuestas 5xx o límites de tasa (con espera). No reintentes errores permanentes como direcciones inválidas, números bloqueados o rebotes duros; márcalos como fallos y hazlos visibles para que alguien corrija los datos en vez de seguir spameando.

¿Cómo manejan los trabajadores reinicios o caídas a mitad de envío?

Un worker puede escanear trabajos atascados en sending con una marca temporal antigua, devolverlos a estado reintentable y probar de nuevo con backoff. Esto funciona solo si cada trabajo tiene estado registrado (intentos, timestamps, último error) y la idempotencia evita envíos dobles.

¿Qué datos del trabajo necesito para hacer observable la entrega de notificaciones?

Significa que puedas responder si es seguro reintentar. Almacena estados claros como pending, processing, sent y failed, además del conteo de intentos y el último error. Eso facilita soporte y depuración, y permite recuperar el sistema sin adivinar.

¿Cómo implementaría este patrón en AppMaster?

Modela una tabla outbox en el Data Designer, escribe la actualización de negocio y la fila outbox en una sola transacción, y luego ejecuta la lógica de envío y reintentos en un proceso en segundo plano separado. Mantén una clave de idempotencia por mensaje y registra los intentos para que despliegues, reintentos y reinicios no creen duplicados.

Fácil de empezar
Crea algo sorprendente

Experimente con AppMaster con plan gratuito.
Cuando esté listo, puede elegir la suscripción adecuada.

Empieza