Construyendo un Sistema de Notificaciones Push Confiable y Consciente de la Zona Horaria
Una aplicación de salud y bienestar necesitaba enviar recordatorios diarios personalizados — recordatorios de comidas, chequeos de estado de ánimo, avisos de hidratación, señales de sueño y recordatorios personalizados — a miles de usuarios en todo el mundo. El reto: cada notificación debía llegar a la hora local correcta, exactamente una vez, y nunca a un dispositivo inactivo. Diseñamos y construimos la tubería distribuida que hace eso posible.
El Desafío
- Corrección de zona horaria a escala. Un "recordatorio de desayuno a las 9 AM" significa algo diferente para cada usuario. Enviar en hora del servidor haría que alguien en Sídney recibiera el aviso a las 3 AM. Cada notificación necesitaba resolverse al momento local del usuario.
- Sin spam, sin duplicación. La programación de crons inevitablemente se superpone y se re-ejecuta. Sin garantías estrictas, un solo usuario podría recibir el mismo recordatorio de "hora de almuerzo 🥗" dos o tres veces — un camino rápido hacia la desinstalación.
- Los dispositivos son poco fiables. Los usuarios desinstalan aplicaciones, revocan permisos y rotan tokens de push constantemente. Enviar notificaciones a ciegas a tokens obsoletos desperdicia recursos y corrompe las métricas de entrega.
- Tiempo preciso sin un programador de fuerza bruta. Entregar cientos de notificaciones en minutos exactos — sin un cron martillando la base de datos cada 60 segundos — requería un mecanismo más inteligente que la simple encuesta.
Nuestra Solución
Construimos una tubería de tres etapas que separa claramente qué enviar, cuándo enviarlo y realmente enviarlo — para que cada etapa pueda fallar y recuperarse de manera independiente. La base de datos es la fuente de verdad, una cola de mensajes maneja el tiempo preciso, y una sola capa de trabajadores se comunica con el proveedor de push.

Arquitectura
- Expo-notifications es un cliente React Native con canales nativos, sonidos únicos y enlaces profundos que utiliza un formato de token único y una API de entrega para iOS y Android.
- Backend NestJS con expo-server-sdk como la abstracción unificada de push sobre FCM y APNs.
- MongoDB como la fuente de verdad — NotificationMessage, NotificationToken, y NotificationCounter colecciones.
- Colas de retraso ActiveMQ (STOMP), una por categoría (comida, estado de ánimo, actividad, seguridad, recordatorios), para una entrega programada precisa.
- Crons de creador que generan registros de notificaciones resueltas por zona horaria por usuario.
- Trabajadores consumidores que se suscriben a cada cola y realizan la validación final antes de enviar.
- AWS ECS Fargate ejecutando crons y consumidores; ActiveMQ en una instancia dedicada de EC2.
Características Clave
- Programación consciente de la zona horaria. Usando date-fns-tz, se calcula la hora local de envío de cada usuario, se convierte de nuevo a UTC para almacenamiento, y se limita por ventanas de fecha UTC para garantizar un recordatorio por día.
- Idempotencia reforzada por la base de datos. Un índice único parcial en mensajes pendientes hace imposible la creación de duplicados — incluso cuando un cron se ejecuta dos veces:
| // Único solo mientras el mensaje esté PENDING y no eliminado schema.index( { userId: 1, notificationTokenId: 1, category: 1, label: 1, scheduledAt: 1 }, { unique: true, partialFilterExpression: { status: 'pending', isDeleted: false } } ); |
3. Entrega precisa a través de colas de retraso. En lugar de un cron por minuto, el programador encola mensajes ahora pero difiere la entrega al minuto exacto debido usando el encabezado de retraso programado de ActiveMQ:
| client.send(`/queue/${queueName}`, { persistent: 'true', 'AMQ_SCHEDULED_DELAY': String(delayMs), // entregado exactamente cuando se debe }, JSON.stringify(message)); |
4. Un solo token activo por dispositivo. Un índice único parcial garantiza exactamente un token activo por dispositivo; los nuevos inicios de sesión retiran limpiamente el token antiguo, con reintentos de retroceso exponencial para sobrevivir a inicios de sesión concurrentes.
5. Verificación de recibos + limpieza automática. Después de enviar, consultamos los recibos de Expo. Una respuesta de DeviceNotRegistered desactiva inmediatamente el token inactivo para que nunca desperdiciemos un envío en él nuevamente.
| f (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') { await this.deactivateToken(token); // dejar de enviar a dispositivos inactivos } |
6. Límite de fallos. Cada dispositivo tiene un contador de reintentos; después de 3 fallos consecutivos, el token se retira automáticamente — sin bucles infinitos, sin tokens zombis.
7. Respetar la intención del usuario en el momento del envío. La preferencia de notificación se vuelve a verificar por el consumidor en la entrega, no solo en la programación — por lo que un usuario que se da de baja una hora antes de un recordatorio nunca lo recibe. Los mensajes se resuelven en estados terminales honestos: success, failed, o is_missed.
Resultados
- Cada usuario recibe recordatorios a la hora local correcta, en todo el mundo — cero notificaciones fuera de horario.
- Notificaciones duplicadas eliminadas por completo a través de la idempotencia a nivel de base de datos.
- Los tokens de dispositivos inactivos y obsoletos se detectan y retiran automáticamente, manteniendo la entrega limpia.
- La fiabilidad se incrementa y la carga de la base de datos se reduce con una entrega precisa y exacta al minuto sin un programador de fuerza bruta.
Pila Tecnológica
React Native · Expo Notifications · NestJS · TypeScript · MongoDB · ActiveMQ (STOMP) · expo-server-sdk · date-fns-tz · AWS ECS Fargate

