Como disenar un sistema de notificaciones en tiempo real con WebSockets y push notifications para tu app a medida
Llevo ocho anos construyendo backends para plataformas con requisitos de tiempo real. Mi contexto mas intenso fue una app de delivery de comida con 200.000 usuarios diarios activos, donde las notificaciones no eran un "nice to have" sino el corazon del producto. Si el repartidor no recibe la alerta del nuevo pedido en menos de tres segundos, la comida se enfria, el cliente cancela y el restaurante pierde dinero. Asi de directo.
Voy a contarte lo que aprendi montando ese sistema, los errores que cometimos y la arquitectura que termino funcionando a escala. No es una guia teorica: es lo que sobrevivio a un Black Friday con 47.000 pedidos simultaneos.
Que diferencia WebSockets de las push notifications (y por que necesitas las dos)
WebSockets: la linea directa mientras el usuario esta mirando la pantalla
Un WebSocket es una conexion TCP persistente y bidireccional entre el cliente y el servidor. A diferencia de HTTP clasico donde el cliente pregunta y el servidor responde, aqui el canal queda abierto. El servidor puede enviar datos al cliente en cualquier momento sin que nadie se lo pida.
En nuestra plataforma de delivery, esto era lo que mantenia actualizado el mapa en tiempo real. Cuando el repartidor se movia, su app enviaba coordenadas GPS cada 4 segundos por WebSocket. El servidor las procesaba y las reenviaba al WebSocket del cliente que estaba viendo el seguimiento de su pedido. Latencia media: 120 milisegundos extremo a extremo. Con HTTP polling habriamos necesitado que el cliente preguntara "oye, donde esta mi repartidor?" cada pocos segundos, multiplicando el trafico por un factor de 15x segun nuestras mediciones.
Las ventajas son claras: latencia minima, comunicacion bidireccional real (el cliente tambien puede enviar datos al servidor por el mismo canal), y una reduccion brutal de sobrecarga frente a polling. La limitacion principal: solo funciona mientras la app esta abierta y la conexion activa. Si el usuario cierra el navegador o la app pasa a segundo plano en el movil, el WebSocket muere.
Push notifications: el toque en el hombro cuando no estas mirando
Las push notifications operan a traves de servicios intermediarios: APNs de Apple, Firebase Cloud Messaging de Google, o la Web Push API para navegadores. El flujo es distinto: tu servidor no habla directamente con el dispositivo del usuario, sino que le pide al servicio de push que entregue el mensaje. El sistema operativo del dispositivo se encarga del resto, incluso si tu app esta cerrada o el telefono bloqueado.
En nuestro caso, las push eran criticas para avisar al cliente de que su pedido estaba listo para recoger o de que el repartidor estaba llegando. Tambien las usabamos para notificar a los repartidores de nuevos pedidos disponibles en su zona. El dato que nos abrio los ojos: los repartidores que recibian push tenian un tiempo de respuesta medio de 23 segundos. Los que dependian solo de abrir la app y mirar, tardaban mas de 3 minutos de media. Esa diferencia se traducia directamente en pedidos perdidos.
El requisito ineludible: el usuario tiene que dar consentimiento explicito. En nuestra experiencia, la tasa de aceptacion variaba entre el 62% en Android y el 45% en iOS. Si no tienes push, necesitas un canal de fallback. Pero eso lo vemos mas adelante.
La arquitectura que sobrevivio a 47.000 pedidos simultaneos
Capa de eventos: separar lo que pasa de a quien se lo cuentas
Este fue probablemente el error mas caro de nuestra primera version. Teniamos la logica de notificaciones metida dentro de cada modulo de negocio. El servicio de pedidos enviaba directamente los WebSocket. El servicio de pagos mandaba sus propias push. El servicio de asignacion de repartidores tenia su propia cola de notificaciones. Tres sistemas distintos, tres puntos de fallo, tres conjuntos de bugs diferentes.
La refactorizacion que nos salvo fue introducir un bus de eventos con Redis Streams (hoy probablemente usaria Kafka si empezara de cero con ese volumen). El concepto es simple: los modulos de negocio publican eventos — "pedido_creado", "pago_confirmado", "repartidor_asignado", "pedido_entregado" — y un servicio de notificaciones centralizado los consume. Ese servicio es el unico que sabe de WebSockets, de push, de emails, de SMS. Los modulos de negocio no tocan nada de eso.
El resultado inmediato: redujimos los bugs relacionados con notificaciones en un 73% en el primer mes. Y cuando quisimos anadir notificaciones por SMS para un piloto con restaurantes premium, fue un cambio de 4 horas en un unico servicio en lugar de tocar media docena de repositorios.
La estructura de un evento tipico en nuestro sistema era algo asi: un JSON con el tipo de evento, un timestamp Unix, el ID del actor que lo genero, el ID del destinatario, un payload con los datos relevantes (ID del pedido, nombre del restaurante, tiempo estimado) y un campo de prioridad. El servicio de notificaciones leia todo eso y decidia que hacer.
Capa de enrutamiento: la logica de "a quien, por donde y cuando"
Aqui es donde se toman las decisiones reales. Para cada evento que llega al servicio de notificaciones, hay tres preguntas:
Primera: a quien va dirigido. Parece obvio pero no lo es. Un evento "pedido_retrasado" debe notificar al cliente, pero tambien puede notificar al restaurante si el retraso supera los 15 minutos, y al equipo de operaciones si supera los 30. Teniamos una tabla de reglas de enrutamiento en base de datos que los product managers podian modificar sin tocar codigo. Fue una de las mejores decisiones de diseno que tomamos.
Segunda: por que canal. La logica que implementamos seguia esta cascada. Si el destinatario tiene una sesion WebSocket activa, se envia por WebSocket — latencia minima, coste cero. Si no hay sesion WebSocket y el evento tiene prioridad alta o critica, se envia por push notification. Si no hay permisos de push o la prioridad es baja, se deposita en la bandeja de notificaciones interna (que el usuario vera la proxima vez que abra la app). Y para eventos criticos tipo "tu cuenta ha sido comprometida", se anabia un email como canal adicional sin importar el resto.
Tercera: cuando. No todas las notificaciones deben salir al instante. Si un repartidor recibe 12 pedidos nuevos en su zona en 30 segundos, mandarle 12 push seguidas es una forma segura de que desactive las notificaciones. Agrupabamos eventos similares con una ventana configurable — por defecto 15 segundos — y enviabamos un resumen: "Tienes 12 nuevos pedidos disponibles en tu zona".
Persistencia: porque las notificaciones que se pierden son las que mas duelen
Cada notificacion que genera nuestro sistema se guarda en PostgreSQL con un estado que evoluciona: pendiente, enviada, entregada, leida. Ademas registramos el canal por el que se envio, el timestamp de cada transicion de estado, y si hubo reintentos.
Esto no es solo por auditoria (aunque en sectores regulados es obligatorio). La persistencia nos resuelve tres problemas practicos enormes. Primero: cuando un usuario reconecta despues de perder cobertura, podemos enviarle todas las notificaciones que se genero mientras estaba desconectado, ordenadas cronologicamente. Segundo: si FCM nos devuelve un error al intentar enviar una push, podemos reintentar con backoff exponencial sin perder el mensaje. Tercero: los datos de entrega y lectura alimentan las metricas que nos permiten optimizar el sistema — descubrimos que las notificaciones enviadas entre las 12:00 y las 14:00 tenian un 34% mas de tasa de lectura que las de las 09:00, lo que nos llevo a ajustar los horarios de envio de notificaciones no urgentes.
La noche que el polling nos hundio (y como WebSockets nos rescato)
Antes de contarte la implementacion tecnica limpia, dejame explicarte por que llegamos ahi. Nuestra primera version usaba HTTP long-polling. El cliente abria una peticion HTTP al servidor, el servidor la mantenia abierta hasta que habia algo que notificar o hasta que pasaban 30 segundos, y entonces el cliente inmediatamente abria otra. Funcionaba razonablemente bien con 15.000 usuarios concurrentes. Incluso con 40.000.
Pero una noche de noviembre, un restaurante de hamburguesas bastante conocido lanzo una promo flash a traves de nuestra plataforma: hamburguesas a 1 euro durante 2 horas. Lo anunciaron en Instagram a las 20:45. A las 20:52, teniamos 127.000 sesiones de polling concurrentes. Cada una de esas sesiones significaba una conexion HTTP abierta en nuestro balanceador de carga. NGINX empezaba a rechazar conexiones nuevas porque habiamos alcanzado el limite de file descriptors. Los healthchecks del balanceador empezaron a fallar. Las instancias se marcaban como unhealthy. El autoscaler intentaba levantar nuevas, pero tardaba 45 segundos en arrancar cada instancia de Node.js con sus dependencias.
Durante 11 minutos, aproximadamente el 40% de los usuarios no recibia notificaciones. Los repartidores no veian pedidos nuevos. Los clientes no sabian si su pedido se habia confirmado. El restaurante nos llamo furioso. Perdimos unos 2.800 pedidos segun la estimacion del equipo de datos.
Al dia siguiente empezamos a migrar a WebSockets. La diferencia fundamental: una conexion WebSocket, una vez establecida, consume mucha menos memoria y CPU que el ciclo constante de abrir-cerrar-abrir conexiones HTTP. Con la misma infraestructura que apenas aguantaba 127.000 sesiones de polling, sostuvimos sin despeinarnos 310.000 conexiones WebSocket simultaneas en las pruebas de carga.
Implementacion tecnica de WebSockets que funciona en produccion
Eligiendo el servidor correcto segun tu stack
En Node.js tienes dos caminos principales. Socket.IO es la opcion con pilas incluidas: reconexion automatica, sistema de salas (rooms) para agrupar usuarios por contexto, fallback automatico a long-polling si el WebSocket falla, y namespaces para separar distintos tipos de trafico. La alternativa es la libreria nativa ws, que es mas ligera pero te obliga a implementar la reconexion y las salas a mano. Nosotros usabamos Socket.IO en el backend de clientes y ws puro en el backend de repartidores, porque los repartidores usaban una app nativa que manejaba su propia logica de reconexion.
Si tu stack es Python, Django Channels es la referencia. Si estas en Java o Kotlin, Spring WebSocket con STOMP funciona muy bien y se integra de forma natural con el ecosistema Spring. En Go, Gorilla WebSocket era el estandar de facto durante anos (ahora esta en modo mantenimiento pero sigue siendo solido).
Autenticacion: el handshake es tu unica oportunidad
Cuando un cliente abre una conexion WebSocket, el primer paso es el handshake HTTP que luego se "upgradea" a WebSocket. Ese handshake es el momento de autenticar. Nosotros enviamos un JWT en el header Authorization durante el upgrade. El servidor lo valida, extrae el userId, y asocia esa conexion WebSocket a ese usuario en un mapa en memoria.
En un sistema con un solo servidor, ese mapa en memoria es suficiente. Pero en cuanto tienes dos o mas instancias detras de un balanceador, necesitas un registro compartido. Nosotros usamos Redis como fuente de verdad: cuando se establece una conexion, registramos en Redis el par userId → serverId con un TTL de 5 minutos que se renueva con cada heartbeat. Cuando el servicio de notificaciones necesita enviar algo al usuario X, consulta Redis para saber en que servidor esta conectado, y publica el mensaje en un canal de Redis que ese servidor esta escuchando.
Un detalle que nos costo un fin de semana de debugging: los tokens JWT expiran. Si el token expira mientras la conexion WebSocket esta activa, no pasa nada inmediato — la conexion sigue abierta. Pero si el usuario reconecta con un token expirado, la reconexion falla silenciosamente con Socket.IO en la configuracion por defecto. La solucion: implementamos un mecanismo donde el servidor envia un mensaje de tipo "token_expiring" 5 minutos antes de la expiracion, y el cliente renueva el token y lo envia por el mismo WebSocket sin necesidad de reconectar.
Reconexion y resiliencia: lo que pasa cuando la red falla
Las conexiones WebSocket se caen. Pasa constantemente: el usuario entra en un ascensor, cambia de WiFi a datos moviles, el servidor se reinicia durante un despliegue. La reconexion automatica con backoff exponencial es obligatoria. Nuestro esquema era: primer reintento a los 500ms, segundo a 1s, tercero a 2s, cuarto a 4s, y asi hasta un maximo de 30 segundos entre reintentos. Socket.IO hace esto por defecto, pero si usas ws tienes que implementarlo tu.
El truco critico esta en la reconciliacion al reconectar. Cuando el cliente se reconecta, envia el ID de la ultima notificacion que recibio. El servidor consulta la base de datos, recupera todas las notificaciones posteriores a ese ID que estan en estado "pendiente" o "enviada" para ese usuario, y las envia en orden. Sin este mecanismo, el usuario pierde notificaciones cada vez que se desconecta un momento. Y te enteras cuando los tickets de soporte empiezan a subir.
Push notifications: la otra mitad del puzzle
Web Push con VAPID: notificaciones en el navegador sin app nativa
El protocolo Web Push permite enviar notificaciones a navegadores — Chrome, Firefox, Edge — sin necesidad de una app nativa. El flujo completo tiene varios pasos, pero una vez montado funciona de forma fiable.
Primero generas un par de claves VAPID (Voluntary Application Server Identification). Estas claves identifican tu servidor ante los servicios de push de los navegadores. Segundo, cuando el usuario acepta recibir notificaciones, el Service Worker de tu web pide una suscripcion al navegador. El navegador devuelve un objeto con un endpoint unico (una URL del servicio de push del navegador) y unas claves de cifrado. Tercero, almacenas ese objeto de suscripcion en tu base de datos asociado al usuario. Cuarto, cuando quieres enviar una notificacion, cifras el payload con las claves del suscriptor y haces un POST al endpoint con tus credenciales VAPID.
Un consejo nacido del dolor: los endpoints de suscripcion caducan y cambian. El navegador puede revocar una suscripcion en cualquier momento. Tu servicio de push tiene que manejar los errores 404 y 410 que devuelven los endpoints, y eliminar las suscripciones invalidas de tu base de datos. Nosotros haciamos una limpieza semanal ademas de la gestion reactiva, y consistentemente eliminabamos entre un 3% y un 5% de suscripciones obsoletas.
Push en apps moviles: FCM como capa de unificacion
Firebase Cloud Messaging te permite enviar push tanto a iOS como a Android desde un unico backend. Tu servidor envia el mensaje a la API de FCM con el token del dispositivo, y FCM se encarga de enrutarlo al dispositivo correcto a traves de APNs (en iOS) o del propio canal de Google (en Android).
La gestion de tokens es el punto donde mas equipos tropiezan. Los tokens de FCM cambian cuando el usuario reinstala la app, cuando restaura un backup, cuando borra los datos de la app, o simplemente de forma periodica. FCM te avisa de tokens invalidos devolviendo un error especifico en la respuesta al envio. Tu backend tiene que capturar esos errores y eliminar o actualizar los tokens correspondientes. Si no lo haces, acabas enviando push a tokens fantasma, lo que incrementa tu tasa de error y puede hacer que FCM te aplique throttling.
En nuestro sistema, cada usuario podia tener multiples tokens (telefono personal, tablet, telefono de trabajo). Manteniamos un registro de tokens por usuario con un timestamp de "ultimo uso exitoso". Los tokens sin uso exitoso en 60 dias se marcaban como inactivos y dejaban de recibir push.
Deep linking: llevar al usuario exactamente donde necesita estar
Una notificacion que dice "Tu pedido esta listo" y abre la pantalla de inicio de la app es una notificacion que falla en su mision. El deep linking es la tecnica que permite que la notificacion abra directamente la pantalla del pedido especifico, con toda la informacion relevante ya cargada.
En la practica, esto significa incluir en el payload de la notificacion una ruta interna de la app — algo como /orders/ORD-2847291/tracking — y que el manejador de notificaciones en el cliente interprete esa ruta y navegue a ella. En apps nativas, esto se implementa con Universal Links (iOS) y App Links (Android). En web, es simplemente una URL con parametros.
El impacto es medible: cuando implementamos deep linking, la tasa de accion tras recibir una notificacion subio del 12% al 38%. Los usuarios no solo abrian la app, sino que interactuaban con el contenido relevante.
Escalabilidad: de 10.000 a 500.000 conexiones concurrentes
Redis Pub/Sub para sistemas distribuidos
Cuando tienes multiples servidores de WebSocket detras de un balanceador, un usuario conectado al servidor A no puede recibir mensajes publicados en el servidor B. Redis Pub/Sub resuelve esto: cada servidor de WebSocket se suscribe a un canal de Redis. Cuando el servicio de notificaciones quiere enviar un mensaje, lo publica en Redis. Todos los servidores lo reciben, y el que tiene la conexion del usuario destinatario lo reenvia por WebSocket.
Para volumenes por encima de 100.000 conexiones concurrentes, sustituimos Redis Pub/Sub simple por Redis Streams con consumer groups. La diferencia clave: Pub/Sub es fire-and-forget (si un servidor esta caido cuando se publica el mensaje, lo pierde), mientras que Streams garantiza que el mensaje persiste hasta que es consumido y confirmado. Esto nos dio durabilidad sin tener que montar un Kafka completo, que habria sido matar moscas a canonazos para nuestro volumen.
Agrupacion de notificaciones: menos ruido, mas senal
Ya lo mencione antes pero quiero profundizar porque es un tema que muchos equipos ignoran y luego pagan con desinstalaciones. Nuestro sistema de agrupacion funcionaba con ventanas temporales configurables por tipo de evento. Nuevos pedidos disponibles para repartidores: ventana de 15 segundos. Mensajes de chat entre cliente y repartidor: sin agrupacion, envio inmediato. Actualizaciones de estado de pedido: ventana de 5 segundos (para evitar el caso de que un pedido pase de "en preparacion" a "en camino" en 3 segundos y el usuario reciba dos push casi identicas).
La reduccion de volumen fue del 41% en push enviadas, y la tasa de desactivacion de notificaciones bajo del 8.2% mensual al 3.1%. Menos notificaciones pero mas relevantes.
Monitorizacion: los cuatro numeros que miro cada manana
Despues de anos operando el sistema, estos son los cuatro indicadores que realmente importan. Conexiones WebSocket activas por servidor, que te dice si la carga esta distribuida de forma uniforme y si algun servidor esta cerca de su limite. Tasa de entrega de push, que deberia estar por encima del 95% — si baja, probablemente tienes tokens obsoletos o problemas con FCM/APNs. Latencia evento-a-cliente, medida como el tiempo desde que el evento se publica en el bus hasta que el cliente confirma la recepcion — nuestro p95 era de 340ms, nuestro objetivo era mantenerlo por debajo de 500ms. Y tasa de reconexiones por minuto, que es tu indicador mas sensible de problemas de red o de estabilidad del servidor — un pico de reconexiones suele preceder a una caida en 2-3 minutos.
Teniamos dashboards en Grafana para todo esto, con alertas en PagerDuty si algun indicador salia del rango normal. La inversion en observabilidad nos ahorro literalmente noches de sueno.
Decisiones de diseno que definen tu arquitectura desde el primer dia
Antes de escribir una linea de codigo, hay cuatro preguntas que determinan si tu sistema va a escalar o te va a explotar en la cara.
Volumen de usuarios concurrentes. Con 50 usuarios concurrentes, un unico servidor de Node.js con Socket.IO y una base de datos SQLite te sobra. Con 5.000, necesitas al menos Redis como registro compartido y probablemente dos instancias detras de un balanceador con sticky sessions. Con 50.000 o mas, estas hablando de Redis Streams o Kafka, multiples pods de WebSocket sin estado, y un servicio de notificaciones separado con su propia escalabilidad horizontal. Nuestra plataforma paso por las tres fases en 18 meses. Cada transicion fue dolorosa porque no la planificamos con suficiente antelacion.
Criticidad y garantia de entrega. No todas las notificaciones valen lo mismo. Un "tienes un nuevo me gusta" puede perderse y nadie llora. Un "tu transferencia bancaria de 15.000 euros ha sido ejecutada" no puede perderse jamas. Si tienes notificaciones criticas, necesitas persistencia, reintentos, confirmacion de entrega, y un canal de fallback (email o SMS). Si todas tus notificaciones son informativas, puedes simplificar mucho la arquitectura y aceptar un modelo best-effort.
Canales requeridos y su interaccion. Solo web, web mas movil, con fallback a email, con SMS para emergencias. Cada canal anade complejidad, pero el error es tratarlos como independientes. El usuario es uno, y si recibe la misma notificacion por WebSocket, por push y por email, va a desinstalar tu app. La logica de deduplicacion entre canales no es trivial y tiene que ser parte del diseno desde el dia uno.
Preferencias del usuario. Dar al usuario control sobre que notificaciones recibe y por que canal no es un lujo: es un requisito legal en muchas jurisdicciones (GDPR en Europa, LOPD en Espana) y una necesidad practica para mantener tasas de retencion sanas. Nuestro panel de preferencias de notificaciones redujo las quejas relacionadas con "demasiadas notificaciones" en un 67% tras su lanzamiento.
Lo que construiria hoy si empezara de cero
Despues de tres iteraciones del sistema y una migracion en caliente con 200.000 usuarios activos, tengo opiniones bastante formadas. Usaria Socket.IO para WebSockets salvo que tuviera requisitos de rendimiento extremos que justificaran Go con Gorilla. Redis Streams como bus de eventos hasta superar las 500.000 conexiones concurrentes, momento en el que migraria a Kafka. FCM como unico proveedor de push movil, con Web Push API para navegadores. PostgreSQL para persistencia de notificaciones con particionado por fecha (las notificaciones de hace 6 meses rara vez se consultan pero deben existir). Y un servicio de preferencias de usuario separado que actue como filtro antes del enrutamiento, no despues.
No es una arquitectura perfecta. Es una arquitectura que ha sobrevivido a flash sales, a picos de trafico del 800%, a despliegues que salieron mal a las tres de la manana, y a un cambio de proveedor de infraestructura en medio de una ronda de inversion. Eso vale mas que cualquier diagrama bonito en una presentacion.
Si estas disenando un sistema de notificaciones en tiempo real para tu aplicacion empresarial y quieres evitar los errores que yo tarde anos en descubrir, hablemos sobre tu caso concreto. Podemos revisar tu arquitectura actual, identificar los cuellos de botella y definir un plan de implementacion realista para tu volumen y tus requisitos.