Cómo diseñar un sistema de notificaciones multicanal con push, email y webhooks para mejorar la retención en tu aplicación web a medida
Te lo digo directamente: la diferencia entre una aplicación web que retiene usuarios y una que los pierde en las primeras semanas casi siempre se reduce a un sistema de notificaciones bien diseñado. Y no, no hablo de bombardear con emails genéricos. Hablo de una arquitectura que decide qué canal usar, cuándo enviar, cómo personalizar y cómo medir el impacto real de cada mensaje en la retención de tu producto.
Llevo años viendo proyectos que tratan las notificaciones como un "ya lo haremos al final". Error. En esta guía te cuento cómo diseñar desde cero un sistema de notificaciones multicanal —push, email, SMS y webhooks— orientado a aplicaciones web a medida. Desde la arquitectura de colas hasta la lógica de throttling, pasando por esquemas de base de datos, sistemas de preferencias y frameworks de A/B testing aplicados a notificaciones.
La arquitectura real de un sistema de notificaciones multicanal
Un sistema de notificaciones robusto no es un simple wrapper sobre SendGrid o Firebase. La realidad es que necesitas una arquitectura con responsabilidades separadas que permita escalar cada canal de forma independiente y evolucionar sin reescribir el core. Punto.
Los cinco componentes que necesitas
El flujo típico se descompone en cinco capas:
Event Emitter (Productor de eventos): Tu aplicación genera eventos de negocio (nuevo pedido, comentario, cambio de estado). Estos eventos se publican en un message broker como RabbitMQ, Amazon SQS o Redis Streams.
Notification Router (Orquestador): Servicio que consume eventos y decide qué notificaciones generar. Aplica reglas de negocio: ¿el usuario tiene activada esta categoría? ¿Qué canal prefiere? ¿Está dentro del horario permitido?
Channel Adapters (Proveedores): Workers especializados por canal. Cada adapter habla con un proveedor externo: Firebase Cloud Messaging para push, SendGrid o Amazon SES para email, Twilio para SMS, y tu propio dispatcher para webhooks.
Template Engine: Motor de plantillas que renderiza el contenido según canal, idioma y variante de A/B test. Handlebars o Liquid funcionan bien; para email, MJML combinado con un preprocesador.
Delivery Tracker: Almacena el estado de cada notificación (queued, sent, delivered, opened, clicked, failed) y alimenta el sistema de analytics.
El esquema de base de datos que lo sostiene todo
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
event_type VARCHAR(100) NOT NULL,
channel VARCHAR(20) NOT NULL, -- 'push', 'email', 'sms', 'webhook'
status VARCHAR(20) NOT NULL DEFAULT 'queued',
template_id VARCHAR(100),
payload JSONB NOT NULL,
metadata JSONB DEFAULT '{}',
scheduled_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
opened_at TIMESTAMPTZ,
clicked_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failure_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_notifications_user_status ON notifications(user_id, status);
CREATE INDEX idx_notifications_scheduled ON notifications(scheduled_at) WHERE status = 'queued';
CREATE INDEX idx_notifications_event_type ON notifications(event_type, created_at);
Este esquema soporta auditoría completa del ciclo de vida de cada notificación. El campo payload almacena los datos dinámicos que inyectará el template engine, y metadata guarda información contextual como la variante de test o el batch al que pertenece.
Herramientas y stack recomendado
Para proyectos que necesitan control total, te recomiendo la combinación de Novu como capa de orquestación open-source con proveedores especializados por canal. Es el mejor equilibrio entre flexibilidad y velocidad de implementación. Novu te da un notification router con UI de gestión, digests, preferencias de usuario y multi-tenancy out of the box.
Si prefieres construir desde cero (mayor control, menor dependencia), un stack que he visto funcionar muy bien en producción es: Node.js o Go para los workers, BullMQ sobre Redis para las colas con prioridad, PostgreSQL para persistencia y Prometheus + Grafana para observabilidad.
Lógica de selección de canal: no todo merece un push
No todos los mensajes merecen el mismo canal. Un sistema inteligente aplica una matriz de decisión basada en urgencia, tipo de contenido y preferencias del usuario. Parece obvio, pero te sorprendería la cantidad de proyectos que meten todo por email.
Matriz de canales
| Criterio | Push | SMS | Webhook | |
|---|---|---|---|---|
| Urgencia alta (acción inmediata) | Si | No | Si | Si |
| Contenido largo/rico | No | Si | No | No |
| Transaccional (recibos, confirmaciones) | No | Si | No | Si |
| Re-engagement (usuario inactivo) | Si | Si | No | No |
| Integraciones B2B | No | No | No | Si |
| Coste por mensaje | Gratis | ~0.001€ | ~0.05€ | Gratis |
Lógica de fallback
Ahora la parte que marca diferencia. Implementa una cadena de fallback para mensajes críticos. Si el push no se entrega en 5 minutos (el dispositivo está offline), escala a email. Si el email no se abre en 2 horas y el mensaje es urgente, envía SMS. Esta lógica se codifica en el Notification Router:
interface ChannelStrategy {
primary: Channel;
fallback: Channel[];
escalation_delays: Record<Channel, number>; // ms
max_attempts: number;
}
const urgentStrategy: ChannelStrategy = {
primary: 'push',
fallback: ['email', 'sms'],
escalation_delays: {
email: 5 * 60 * 1000, // 5 min sin delivery → email
sms: 2 * 60 * 60 * 1000 // 2h sin open → sms
},
max_attempts: 3
};
Firebase Cloud Messaging para push
FCM sigue siendo el estándar de facto para push en web y móvil. Para aplicaciones web a medida, la integración con Service Workers permite notificaciones push incluso con la pestaña cerrada. Los puntos clave de implementación que no puedes saltarte:
- Registra el Service Worker con
firebase-messaging-sw.jsy gestiona el token de dispositivo en tu backend. - Almacena tokens por usuario y dispositivo (un usuario puede tener múltiples dispositivos activos).
- Implementa la renovación de tokens: FCM los invalida periódicamente y tu backend debe actualizar la referencia.
- Usa topic messaging para broadcasts a segmentos grandes sin iterar sobre tokens individuales.
Preferencias de usuario: el equilibrio entre control y simplicidad
Un sistema de preferencias mal diseñado genera dos problemas: opt-outs masivos (si no das control) o abandono del panel de preferencias (si es demasiado complejo). Traducido: necesitas el punto justo.
Modelo de datos para preferencias
CREATE TABLE notification_preferences (
user_id UUID REFERENCES users(id),
category VARCHAR(50) NOT NULL,
channel VARCHAR(20) NOT NULL,
enabled BOOLEAN DEFAULT true,
quiet_hours_start TIME,
quiet_hours_end TIME,
frequency_cap INTEGER, -- max por día en esta categoría/canal
PRIMARY KEY (user_id, category, channel)
);
CREATE TABLE notification_categories (
slug VARCHAR(50) PRIMARY KEY,
display_name VARCHAR(100) NOT NULL,
description TEXT,
default_channels VARCHAR(20)[] DEFAULT '{email}',
is_mandatory BOOLEAN DEFAULT false, -- legales/seguridad no se desactivan
sort_order INTEGER DEFAULT 0
);
Categorías que funcionan
Agrupa notificaciones en categorías semánticas que el usuario pueda entender sin contexto técnico:
- Actividad en tu cuenta: logins, cambios de contraseña, dispositivos nuevos (obligatoria).
- Actualizaciones de producto: nuevas funcionalidades, cambios en la plataforma.
- Colaboración: menciones, comentarios, asignaciones.
- Transaccional: facturas, confirmaciones de pedido, envíos.
- Marketing/engagement: tips, newsletters, ofertas.
El panel de preferencias debe ofrecer toggles por categoría y canal, con la posibilidad de definir quiet hours globales (por ejemplo, no molestar de 22:00 a 08:00). Esto se implementa con una verificación en el Notification Router antes de encolar el mensaje al channel adapter.
Quiet hours y zonas horarias
Almacena la zona horaria del usuario (detectada en onboarding o inferida del navegador con Intl.DateTimeFormat().resolvedOptions().timeZone) y calcula las quiet hours en UTC antes de evaluar si un mensaje puede enviarse inmediatamente o debe programarse para la siguiente ventana activa.
Garantías de entrega: idempotencia y gestión de errores
En un sistema distribuido, "enviar una notificación" no es una operación atómica. Necesitas garantías de entrega y mecanismos para evitar duplicados. Esto es lo que separa un sistema amateur de uno profesional.
At-least-once delivery con deduplicación
Diseña el sistema para at-least-once delivery (es preferible enviar dos veces a no enviar nunca) y añade deduplicación en el channel adapter:
async function sendNotification(notification: Notification): Promise<void> {
const deduplicationKey = `${notification.user_id}:${notification.event_type}:${notification.channel}:${notification.payload_hash}`;
const alreadySent = await redis.get(`dedup:${deduplicationKey}`);
if (alreadySent) {
logger.info('Duplicate notification suppressed', { deduplicationKey });
return;
}
await channelAdapter.send(notification);
await redis.set(`dedup:${deduplicationKey}`, '1', 'EX', 3600); // TTL 1h
}
Retry con exponential backoff
Para fallos transitorios (timeouts, rate limits de proveedores), implementa retry con backoff exponencial y jitter:
- Primer reintento: 1s + jitter aleatorio (0-500ms)
- Segundo reintento: 4s + jitter
- Tercer reintento: 16s + jitter
- Después de 3 fallos: marca como
failed, alerta al equipo de ops
Dead Letter Queue
Los mensajes que fallan repetidamente van a una Dead Letter Queue (DLQ) donde pueden inspeccionarse manualmente o reprocesarse tras resolver el problema con el proveedor. En BullMQ esto se configura con attempts y backoff en las opciones del job, y la DLQ es una cola separada donde BullMQ mueve los jobs fallidos automáticamente.
Circuit Breaker para proveedores
Si SendGrid o Twilio tienen una incidencia, no quieres que tu cola se llene de reintentos inútiles. Implementa un circuit breaker (patrón half-open/closed/open) que detenga los envíos al proveedor afectado y active el canal de fallback hasta que el proveedor se recupere. Te ahorra dolores de cabeza enormes en producción.
Templating, personalización y contenido dinámico
El contenido de cada notificación debe adaptarse al canal, al idioma y al contexto del usuario. Un motor de plantillas centralizado evita duplicar lógica de rendering en cada adapter. Y te lo digo por experiencia: la deuda técnica de templates dispersos se paga cara.
Arquitectura de templates
Cada template se define con variantes por canal:
# templates/order_shipped.yml
id: order_shipped
category: transactional
channels:
email:
subject: "Tu pedido #{{order_id}} está en camino"
template: "order_shipped/email.mjml"
push:
title: "Pedido enviado"
body: "Tu pedido #{{order_id}} llegará el {{delivery_date}}"
icon: "shipping"
action_url: "/orders/{{order_id}}/tracking"
sms:
body: "Tangram: Tu pedido #{{order_id}} está en camino. Seguimiento: {{tracking_url}}"
webhook:
payload_schema: "order_shipped.webhook.json"
MJML para emails responsivos
MJML es el estándar para crear emails HTML responsivos sin sufrir con las tablas anidadas que exigen los clientes de correo. Compila a HTML compatible con Outlook, Gmail y Apple Mail. Combínalo con Handlebars para inyectar variables dinámicas:
<mj-section>
<mj-column>
<mj-text>Hola {{user.first_name}},</mj-text>
<mj-text>Tu pedido <strong>#{{order_id}}</strong> ha sido enviado.</mj-text>
<mj-button href="{{tracking_url}}">Ver seguimiento</mj-button>
</mj-column>
</mj-section>
Localización
Para aplicaciones con usuarios en diferentes regiones de habla hispana (España, LATAM), almacena las traducciones por locale (es-ES, es-MX) y resuelve la variante en el template engine según el perfil del usuario. ICU MessageFormat gestiona bien plurales y géneros.
Throttling, batching y digests: no satures a tu usuario
Ahora la parte incómoda. El mayor enemigo de la retención no es la falta de notificaciones, sino el exceso. Un sistema sin throttling genera unsubscribes, desactivación de push y mala reputación de dominio en email. He visto proyectos destruir meses de trabajo de retención por no poner límites.
Rate limiting por usuario y canal
interface ThrottleConfig {
channel: Channel;
max_per_hour: number;
max_per_day: number;
cooldown_after_interaction: number; // ms tras click/dismiss
}
const defaultThrottles: ThrottleConfig[] = [
{ channel: 'push', max_per_hour: 3, max_per_day: 10, cooldown_after_interaction: 30 * 60 * 1000 },
{ channel: 'email', max_per_hour: 2, max_per_day: 5, cooldown_after_interaction: 60 * 60 * 1000 },
{ channel: 'sms', max_per_hour: 1, max_per_day: 2, cooldown_after_interaction: 4 * 60 * 60 * 1000 },
];
Digest y batching
Para categorías de alta frecuencia (comentarios en un hilo, actividad de equipo), agrupa notificaciones en un digest periódico en lugar de enviar una por una:
- Digest inmediato (30s-2min): Agrupa eventos que llegan en ráfaga. Si recibes 5 comentarios en 30 segundos, envía un solo push: "5 nuevos comentarios en tu proyecto".
- Digest programado (diario/semanal): Para actualizaciones no urgentes, compila un resumen. Esto reduce el volumen de emails y mejora las tasas de apertura porque cada email contiene más valor.
Implementa el digest con una ventana de agregación en Redis usando sorted sets con TTL. Cuando el primer evento llega, programas un job delayed que se ejecutará al final de la ventana y enviará el batch acumulado.
Frequency capping global
Más allá del throttle por canal, implementa un cap global diario por usuario. Si un usuario ya ha recibido su máximo de impactos del día (ej: 15 entre todos los canales), las notificaciones no urgentes se acumulan para el digest del día siguiente. Las transaccionales y de seguridad son la excepción y siempre se envían.
Analytics, A/B testing y optimización: mide o ve a ciegas
Un sistema de notificaciones sin métricas es un sistema ciego. Punto. Necesitas datos para decidir qué funciona, qué sobra y cómo optimizar.
Métricas fundamentales
- Delivery rate: Porcentaje de notificaciones que llegan al dispositivo/inbox. Baja delivery rate en push indica tokens expirados; en email, problemas de reputación de dominio.
- Open rate: Solo medible en email (pixel tracking) y push (click). Objetivo: >25% en transaccional, >15% en engagement.
- Click-through rate (CTR): Porcentaje que interactúa con la CTA. Clave para medir relevancia del contenido.
- Unsubscribe/opt-out rate: Señal de que estás enviando demasiado o contenido irrelevante. Alarma si supera el 0.5% por envío.
- Retención a 7/30/90 días: Correlación entre usuarios que reciben notificaciones y su retención. Segmenta por canal y categoría.
A/B testing en notificaciones
Implementa un framework de experimentación que permita testear:
- Copy: Variantes del asunto del email o body del push.
- Timing: Enviar a las 9:00 vs 14:00 vs basado en actividad histórica del usuario.
- Canal: Push vs email para el mismo evento.
- Frecuencia: Digest diario vs notificaciones individuales.
CREATE TABLE notification_experiments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
event_type VARCHAR(100) NOT NULL,
variants JSONB NOT NULL, -- [{id: 'control', weight: 50, ...}, {id: 'variant_a', weight: 50, ...}]
status VARCHAR(20) DEFAULT 'draft', -- draft, running, completed
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
winner_variant VARCHAR(50),
sample_size_target INTEGER,
confidence_level DECIMAL DEFAULT 0.95
);
CREATE TABLE notification_experiment_assignments (
experiment_id UUID REFERENCES notification_experiments(id),
user_id UUID REFERENCES users(id),
variant_id VARCHAR(50) NOT NULL,
assigned_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (experiment_id, user_id)
);
La asignación a variantes debe ser determinista (hash del user_id + experiment_id) para que un usuario vea siempre la misma variante durante el experimento. Calcula significancia estadística con un test chi-cuadrado o un test de proporciones antes de declarar un ganador.
Webhooks para integraciones B2B
Los webhooks son el canal para notificar a sistemas externos, no a personas. Si tu aplicación web a medida tiene integraciones con ERPs, CRMs o herramientas de terceros, los webhooks permiten que esos sistemas reaccionen a eventos en tiempo real.
Implementación robusta de webhooks salientes:
- Firma HMAC-SHA256 en cada request para que el receptor verifique autenticidad.
- Retry con backoff (misma política que otros canales).
- Timeout agresivo (5 segundos máximo): si el receptor no responde, no bloquees tu cola.
- Panel de administración donde el cliente configure la URL destino, los eventos a los que suscribirse y pueda ver el log de entregas con payloads y responses.
- Versionado del payload: incluye un campo
api_versionpara poder evolucionar el schema sin romper integraciones existentes.
{
"event": "order.shipped",
"api_version": "2026-01-15",
"timestamp": "2026-05-31T14:23:00Z",
"data": {
"order_id": "ord_abc123",
"tracking_number": "1Z999AA10123456784",
"carrier": "SEUR"
},
"signature": "sha256=a1b2c3d4..."
}
Observabilidad del sistema
Configura dashboards en Grafana con paneles para:
- Throughput por canal (mensajes/minuto).
- Latencia de entrega (p50, p95, p99) desde evento hasta sent.
- Tasa de error por proveedor.
- Profundidad de cola (backpressure indicator).
- Coste acumulado por canal (especialmente SMS).
Alertas automáticas cuando la tasa de error supera el 5% en cualquier canal o cuando la latencia p95 supera los 30 segundos.
Conclusión: notificaciones inteligentes, no ruidosas
Diseñar un sistema de notificaciones multicanal no es añadir un sendEmail() en cada endpoint. Es construir una pieza de infraestructura que decide inteligentemente cómo, cuándo y por dónde comunicarse con cada usuario. Un sistema bien diseñado mejora la retención porque cada mensaje es relevante, oportuno y entregado en el canal adecuado.
Los pilares son claros: arquitectura event-driven con colas, lógica de routing basada en preferencias y contexto, templates por canal, throttling para no saturar, y métricas para iterar. La complejidad está en los detalles de implementación: gestión de tokens, reputación de dominio email, retry policies, circuit breakers y la UX del panel de preferencias.
Si estás construyendo una aplicación web a medida y necesitas un sistema de notificaciones que retenga usuarios sin molestarlos, hablemos.