<>Artículo</>
Chat Colaborativo en un SaaS con React y Twilio: Más Allá de los Mensajes de Texto
Resumen
Implementar chat en un SaaS suena simple hasta que los mensajes dejan de ser solo texto. Este artículo documenta cómo integramos Twilio Conversations con React para construir un sistema de colaboración donde los usuarios compartían y discutían assets digitales en tiempo real — y las lecciones que dejó.
Implementar un chat en una aplicación web suena simple hasta que el chat deja de ser solo texto. En 2023, trabajé en la integración de un sistema de mensajería en tiempo real dentro de un SaaS donde los usuarios gestionaban assets digitales, PDFs, imágenes, videos, con un modelo de ownership y permisos. El chat no era un feature secundario: era la capa de colaboración que permitía a los usuarios referenciar, compartir y discutir esos documentos en contexto, tanto en conversaciones 1-a-1 como grupales.
Este artículo documenta las decisiones técnicas, los problemas reales que enfrenté, y lo que aprendí integrando Twilio Conversations con una React SPA que ya tenía su propia complejidad de estado. Al final, incluyo una reflexión sobre cómo abordaría el mismo problema hoy, en 2026.
El Problema: Colaboración Contextual, No Solo Chat
El SaaS permitía que cada usuario gestionara sus propios assets como propietario. Podías subir documentos, organizarlos, y, lo más relevante, otorgar permisos de lectura o escritura a otros usuarios o grupos. Piensa en algo similar a cómo funcionan los permisos en Google Drive, pero integrado dentro de la lógica de negocio de la plataforma.
El requerimiento de chat surgió porque los usuarios necesitaban más que notificaciones: necesitaban conversaciones alrededor de esos assets. No bastaba con enviar un link por email y esperar una respuesta. El flujo natural era: estás trabajando en un documento dentro de la app, necesitas feedback de un colega, abres un chat y le envías una referencia directa al asset. Todo dentro del mismo contexto, sin salir de la aplicación.
Esto definía dos requisitos técnicos inmediatos:
- Mensajería en tiempo real con soporte para conversaciones individuales y grupales, con la capacidad de suscribirse y desuscribirse dinámicamente.
- Referencias a assets del sistema dentro de los mensajes, no solo texto plano o archivos adjuntos genéricos. El chat necesitaba entender que un mensaje podía contener una referencia a un documento gestionado por el SaaS, con sus permisos asociados.
Por Qué Twilio Conversations (y No una Solución Custom)
La primera decisión fue si construir la infraestructura de chat desde cero con Socket.IO puro o usar un servicio gestionado. La evaluación fue rápida:
Socket.IO puro nos daba control total, pero significaba construir y mantener toda la lógica de persistencia de mensajes, presencia de usuarios, gestión de canales, reconexión, y entrega garantizada. Para un equipo enfocado en features de producto, no en infraestructura de comunicación, esto era un desvío considerable.
Twilio Conversations API ofrecía exactamente el modelo que necesitábamos: conversaciones como recurso de primera clase, con soporte nativo para chat individual y grupal. Su SDK de JavaScript se conectaba vía WebSocket internamente y exponía un sistema de eventos que encajaba bien con React. El modelo de suscripción a conversaciones, donde un usuario puede unirse y salir de conversaciones dinámicamente, mapeaba directamente a nuestro caso de uso.
El modelo de costos combinaba una tarifa por Monthly Active User con cargos por almacenamiento de media y las tarifas estándar de SMS/WhatsApp si se usaban esos canales. Para nuestro caso, chat in-app puro sin SMS, el costo dominante era el MAU, lo cual era más predecible que un modelo basado puramente en volumen de mensajes.
La decisión no fue “Twilio es la mejor plataforma de chat del mundo”. Fue: “Twilio nos permite resolver el problema de chat en semanas, no en meses, y nos deja enfocarnos en lo que realmente diferencia al producto, el sistema de assets y permisos”.
Arquitectura de la Solución
La integración se diseñó en tres capas:
El Flujo de Autenticación
Twilio requiere Access Tokens con un ChatGrant para que cada usuario se conecte al SDK desde el frontend. El flujo era:
- El usuario se autentica en el SaaS con su sesión normal.
- El frontend solicita un Access Token a nuestro backend, pasando la identidad del usuario.
- El backend genera el token usando las credenciales de Twilio (Account SID, API Key, API Secret) y lo devuelve al cliente.
- El SDK de Twilio Conversations se inicializa con ese token y establece la conexión WebSocket.
// Backend: Generación del Access Token
import Twilio from 'twilio';
const generateChatToken = (identity: string): string => {
const { AccessToken } = Twilio.jwt;
const { ChatGrant } = AccessToken;
const token = new AccessToken(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_API_KEY,
process.env.TWILIO_API_SECRET,
{ identity, ttl: 3600 }
);
const chatGrant = new ChatGrant({
serviceSid: process.env.TWILIO_SERVICE_SID,
});
token.addGrant(chatGrant);
return token.toJwt();
};
Un detalle que aprendimos por las malas: los tokens expiran. El SDK emite eventos tokenAboutToExpire y tokenExpired que debes manejar para renovar el token sin interrumpir la experiencia del usuario. Si no los manejas, el usuario pierde la conexión WebSocket silenciosamente y deja de recibir mensajes sin ningún feedback visual.
Gestión de Estado con React Context
El state del chat vivía en un React Context dedicado con useReducer. La decisión de no usar Redux fue pragmática: el chat era un dominio aislado del resto del estado de la aplicación, y un Context con reducer nos daba suficiente estructura sin agregar una dependencia global.
// Tipos del estado del chat
interface ChatState {
client: Client | null;
conversations: Map<string, Conversation>;
activeConversation: string | null;
messages: Map<string, Message[]>;
connectionState: 'connecting' | 'connected' | 'disconnected';
}
type ChatAction =
| { type: 'CLIENT_INITIALIZED'; payload: Client }
| { type: 'CONVERSATION_JOINED'; payload: Conversation }
| { type: 'CONVERSATION_LEFT'; payload: string }
| { type: 'MESSAGE_RECEIVED'; payload: { conversationSid: string; message: Message } }
| { type: 'CONNECTION_STATE_CHANGED'; payload: ChatState['connectionState'] };
const chatReducer = (state: ChatState, action: ChatAction): ChatState => {
switch (action.type) {
case 'MESSAGE_RECEIVED': {
const { conversationSid, message } = action.payload;
const existing = state.messages.get(conversationSid) || [];
const updated = new Map(state.messages);
updated.set(conversationSid, [...existing, message]);
return { ...state, messages: updated };
}
// ... otros cases
}
};
El patrón clave era escuchar los eventos del SDK de Twilio y despachar acciones al reducer. Cada evento de Twilio (messageAdded, conversationJoined, participantUpdated) se mapeaba a una acción del reducer, manteniendo el estado de React sincronizado con lo que el SDK reportaba vía WebSocket.
Referencias a Assets en los Mensajes
Este fue el aspecto más interesante de la implementación. Un mensaje en nuestro chat no era solo texto, podía contener una referencia a un asset del SaaS. Técnicamente, esto se resolvió usando los atributos custom que Twilio permite asociar a cada mensaje:
// Enviar un mensaje con referencia a un asset
const sendAssetReference = async (
conversation: Conversation,
assetId: string,
comment: string
) => {
await conversation.sendMessage(comment, {
assetRef: assetId,
assetType: 'document', // 'document' | 'image' | 'video'
permissions: 'inherited', // los permisos se validan server-side
});
};
En el frontend, cuando el componente de chat renderizaba un mensaje con un assetRef en sus atributos, mostraba una tarjeta con preview del documento en lugar de texto plano. El click en esa tarjeta abría el asset viewer del SaaS, respetando los permisos del usuario que estaba viendo el mensaje.
La validación de permisos era crucial: que alguien te referencie un documento en un chat no significa que tengas acceso. El backend verificaba permisos antes de servir el contenido del asset, independientemente de que el mensaje existiera en la conversación.
Los Retos Reales
Reconexión y Estado Stale
El problema más persistente fue la reconexión. Los usuarios dejaban pestañas abiertas por horas, la laptop entraba en sleep, la conexión WiFi fluctuaba. El SDK de Twilio maneja reconexión automática, pero el estado de la UI no se sincroniza mágicamente. Después de una reconexión, podías tener mensajes que llegaron mientras estabas desconectado y que el SDK no re-emitía como eventos.
La solución fue implementar un mecanismo de reconciliación: al detectar una reconexión exitosa (evento connectionStateChanged), recargábamos los últimos N mensajes de cada conversación activa usando la API de paginación del SDK, y los comparábamos con lo que teníamos en el estado local.
Múltiples Conversaciones Activas
En nuestro SaaS, un usuario podía estar suscrito a decenas de conversaciones simultáneamente, una por cada proyecto o grupo de trabajo. Cada conversación generaba eventos independientes. El primer approach naive de suscribirse a todas las conversaciones al cargar la app generaba un volumen de eventos que degradaba la performance del frontend.
La optimización fue suscribirse solo a los eventos de la conversación actualmente visible en pantalla, y mantener una suscripción “ligera” (solo contadores de no-leídos) para el resto. Cuando el usuario cambiaba de conversación, hacíamos el swap de suscripciones.
Cross-Browser: El Fantasma de Safari
Safari en iOS tenía un comportamiento particularmente problemático con las conexiones WebSocket de larga duración. Cuando el usuario cambiaba de pestaña o minimizaba el navegador, Safari podía suspender la conexión WebSocket agresivamente. Al volver, el SDK necesitaba reconectarse, pero el estado visual ya estaba desactualizado. Este edge case nos obligó a agregar un listener de visibilitychange que forzaba una reconciliación del estado al volver a la pestaña.
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && client) {
// Forzar reconciliación al volver a la pestaña
reconcileConversationState(client, dispatch);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [client]);
Lo Que Funcionó y Lo Que No
Funcionó bien:
- React Context + useReducer como state manager del chat. Mantener el dominio de chat aislado del resto del estado global de la app fue una decisión acertada. El reducer actuaba como un traductor limpio entre los eventos de Twilio y el estado de React.
- Los atributos custom de mensajes para referenciar assets. Twilio permite metadata arbitraria en cada mensaje, lo cual nos evitó construir una capa paralela para vincular mensajes con assets del sistema.
- El modelo de suscripción/desuscripción dinámica. Los usuarios entraban y salían de conversaciones según su contexto de trabajo, y la API de Twilio manejaba esto nativamente.
No funcionó tan bien:
- La tokenización de acceso sin un refresh proactivo. Inicialmente esperábamos al evento
tokenExpiredpara renovar. Esto causaba un gap de segundos donde el usuario perdía conectividad. Migramos a renovar proactivamente contokenAboutToExpire. - Confiar ciegamente en la reconexión del SDK. El SDK reconecta el WebSocket, pero no garantiza que tu estado de UI sea consistente después. Tuvimos que construir toda la lógica de reconciliación manualmente.
- Subestimar el costo de las suscripciones masivas. Suscribirse a 30+ conversaciones simultáneamente era técnicamente posible pero ineficiente. El patrón de lazy subscription debió estar desde el día uno.
Actualización 2026: ¿Qué Haría Diferente Hoy?
Han pasado tres años desde esta implementación, y el landscape de comunicación en tiempo real ha cambiado significativamente.
Twilio: De Chat Dedicado a Omnichannel
Twilio deprecó Programmable Chat en julio de 2022 y consolidó todo en Conversations API. La dirección estratégica es clara cuando miras el producto: Conversations API es una API omnichannel, unifica chat, SMS, WhatsApp y Facebook Messenger bajo una misma interfaz. Eso es una fortaleza enorme si tu caso de uso cruza canales, pero también significa que el producto no está diseñado específicamente para competir con plataformas dedicadas de chat in-app como Stream o Sendbird, que nacieron para resolver ese problema exclusivamente.
Esto no es una crítica a Twilio, es una cuestión de enfoque de producto. Twilio es una plataforma de comunicaciones, no una plataforma de chat. Si hoy estás evaluando Twilio para un caso de uso similar al nuestro, chat colaborativo dentro de un SaaS, vale la pena evaluar si las funcionalidades que necesitas (threads, reacciones, moderación, typing indicators avanzados) están cubiertas nativamente por Conversations API o si terminarás construyéndolas sobre la API base.
Las Alternativas Han Madurado
El ecosistema actual ofrece opciones más especializadas:
- Socket.IO sigue siendo la opción open-source para control total. Desde la versión 4.7 (junio 2023) soporta WebTransport como transporte opcional además de WebSocket, aunque con limitaciones prácticas: WebTransport no está disponible en Safari, requiere HTTP/3 en el servidor (que Node.js no soporta nativamente, necesitando paquetes de terceros), y su adopción en infraestructura de producción es todavía limitada. En la práctica, WebSocket sigue siendo el transporte dominante. La comunidad es enorme y la librería está activamente mantenida (v4.8.3, diciembre 2025). Ideal si tu equipo puede gestionar la infraestructura.
- Ably se ha posicionado como la plataforma de referencia para aplicaciones que requieren garantías de entrega y orden de mensajes, con un SLA de 99.999% de uptime. Incluye un React UI Kit y Chat SDK dedicado. Es la opción enterprise.
- Supabase Realtime es interesante si ya usas Supabase como backend. Los cambios en la base de datos se propagan automáticamente como eventos real-time, lo que simplifica la arquitectura para notificaciones y actualizaciones en vivo. Sin embargo, Supabase Realtime no es una solución de chat completa por sí solo: no incluye features como presencia de usuarios, typing indicators, historial paginado, ni UI kits. Necesitarías construir toda esa capa sobre los primitivos de Broadcast y Presence que ofrece. Es una buena base si quieres control total y ya estás en el ecosistema Supabase, pero el esfuerzo de desarrollo es considerablemente mayor que con una plataforma dedicada.
¿Qué Stack Elegiría Hoy?
Para el mismo problema, chat colaborativo con referencia a assets dentro de un SaaS, mi approach en 2026 sería diferente:
- Ably o Socket.IO, dependiendo de si el equipo prefiere un servicio gestionado o control total. Twilio ya no sería mi primera opción para chat in-app puro.
- Zustand en lugar de Context + useReducer para el state management del chat. Zustand ofrece la misma simplicidad que Context pero con mejor performance en actualizaciones parciales y sin el problema de re-renders innecesarios que Context provoca en árboles de componentes grandes.
- Estructura de mensajes tipada con Zod para validar los payloads de mensajes con referencias a assets, en lugar de confiar en atributos custom sin validación.
- Evaluaría integrar un agente de IA en el flujo de chat para funcionalidades como resumen de conversaciones, búsqueda semántica en el historial, o sugerencias contextuales basadas en los assets referenciados.
Conclusión
Implementar chat en tiempo real dentro de un SaaS te obliga a resolver problemas que van más allá de enviar y recibir mensajes: gestión de permisos, referencias contextuales a entidades del sistema, reconciliación de estado después de desconexiones, y performance con múltiples canales activos.
Twilio Conversations fue la decisión correcta en 2023 para nuestro contexto: nos permitió entregar la funcionalidad rápido y enfocarnos en la lógica de negocio que diferenciaba al producto. Pero la lección más valiosa no fue sobre Twilio, fue entender que un SDK que abstrae WebSockets no te libera de entender cómo funciona la comunicación en tiempo real debajo. Cada bug de reconexión, cada estado stale, cada mensaje perdido en Safari te lo recuerda.
El chat en aplicaciones web no es un problema resuelto. Es un problema con muchas soluciones parciales que debes adaptar a tu contexto. La clave es elegir las batallas correctas: usa servicios gestionados para la infraestructura que no te diferencia, y construye custom lo que es core para tu producto.