StackPractices
advanced Por Mathias Paulenko

Construir APIs en Tiempo Real con WebSockets en Serverless

Cómo implementar comunicación bidireccional en tiempo real usando WebSockets con AWS API Gateway, Lambda, DynamoDB y lo que funciona en gestión de conexiones.

Visión general

Los ciclos tradicionales de petición-respuesta HTTP son insuficientes para aplicaciones que requieren actualizaciones en vivo — salas de chat, dashboards en tiempo real, juegos multijugador, tickers de acciones y edición colaborativa. Los WebSockets proporcionan una conexión TCP persistente y bidireccional entre cliente y servidor, permitiendo que los mensajes fluyan en ambas direcciones sin la sobrecarga de handshakes repetidos.

En arquitecturas serverless, los WebSockets requieren gestión de estado de conexión porque las funciones Lambda son efímeras. AWS API Gateway WebSocket API maneja la capa de protocolo WebSocket, mientras que una tabla DynamoDB rastrea las conexiones activas. Las funciones Lambda procesan $connect, $disconnect y rutas personalizadas, transmitiendo mensajes a los IDs de conexión destino. A continuacion se cubre la implementación completa desde la infraestructura hasta el código cliente.

Cuándo usarlo

Usa esta receta cuando:

  • Construyas aplicaciones de chat, notificaciones en vivo o feeds en tiempo real. Consulta Serverless API Gateway para patrones de endpoints HTTP.
  • Transmitas datos en vivo a dashboards o herramientas de monitoreo. Consulta Event-Driven Functions para streaming de datos event-driven.
  • Implementes edición colaborativa o estado de juegos multijugador
  • Reemplaces polling largo o SSE con una conexión persistente más eficiente
  • Transmitas eventos desde servicios backend a clientes conectados. Consulta Scheduled Jobs para push de datos periódico.

Solución

Infraestructura AWS (Terraform)

resource "aws_apigatewayv2_api" "websocket" {
  name                       = "realtime-api"
  protocol_type              = "WEBSOCKET"
  route_selection_expression = "$request.body.action"
}

resource "aws_apigatewayv2_integration" "lambda" {
  api_id           = aws_apigatewayv2_api.websocket.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.websocket.invoke_arn
}

resource "aws_apigatewayv2_route" "connect" {
  api_id    = aws_apigatewayv2_api.websocket.id
  route_key = "$connect"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

resource "aws_apigatewayv2_route" "disconnect" {
  api_id    = aws_apigatewayv2_api.websocket.id
  route_key = "$disconnect"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

resource "aws_apigatewayv2_route" "sendmessage" {
  api_id    = aws_apigatewayv2_api.websocket.id
  route_key = "sendMessage"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

Handler Lambda (Node.js)

const AWS = require('aws-sdk');
const dynamo = new AWS.DynamoDB.DocumentClient();
const apigw = new AWS.ApiGatewayManagementApi({
  endpoint: process.env.WEBSOCKET_ENDPOINT
});

exports.handler = async (event) => {
  const { routeKey, connectionId, domainName, stage } = event.requestContext;

  switch (routeKey) {
    case '$connect':
      await dynamo.put({
        TableName: process.env.CONNECTIONS_TABLE,
        Item: {
          connectionId,
          domainName,
          stage,
          connectedAt: Date.now(),
        }
      }).promise();
      return { statusCode: 200 };

    case '$disconnect':
      await dynamo.delete({
        TableName: process.env.CONNECTIONS_TABLE,
        Key: { connectionId }
      }).promise();
      return { statusCode: 200 };

    case 'sendMessage':
      const body = JSON.parse(event.body);
      const connections = await dynamo.scan({
        TableName: process.env.CONNECTIONS_TABLE
      }).promise();

      const sendPromises = connections.Items.map(async (conn) => {
        try {
          await apigw.postToConnection({
            ConnectionId: conn.connectionId,
            Data: JSON.stringify({
              message: body.message,
              sender: connectionId,
              timestamp: Date.now()
            })
          }).promise();
        } catch (e) {
          if (e.statusCode === 410) {
            await dynamo.delete({
              TableName: process.env.CONNECTIONS_TABLE,
              Key: { connectionId: conn.connectionId }
            }).promise();
          }
        }
      });

      await Promise.all(sendPromises);
      return { statusCode: 200 };

    default:
      return { statusCode: 400 };
  }
};

Cliente (Navegador)

const ws = new WebSocket('wss://your-api-id.execute-api.us-east-1.amazonaws.com/production');

ws.onopen = () => {
  ws.send(JSON.stringify({ action: 'sendMessage', message: 'Hello world!' }));
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data.message);
};

ws.onerror = (error) => console.error('WebSocket error:', error);
ws.onclose = () => console.log('Connection closed');

Reconexión del Cliente con Backoff Exponencial

class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.maxRetries = options.maxRetries || 10;
    this.baseDelay = options.baseDelay || 1000;
    this.maxDelay = options.maxDelay || 30000;
    this.retries = 0;
    this.ws = null;
    this.subscriptions = new Set();
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.retries = 0;
      // Resuscribir a canales previos
      this.subscriptions.forEach((channel) => {
        this.ws.send(JSON.stringify({ action: 'subscribe', channel }));
      });
    };

    this.ws.onclose = () => {
      if (this.retries < this.maxRetries) {
        const delay = Math.min(
          this.baseDelay * Math.pow(2, this.retries),
          this.maxDelay
        );
        this.retries++;
        setTimeout(() => this.connect(), delay);
      }
    };
  }

  subscribe(channel) {
    this.subscriptions.add(channel);
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ action: 'subscribe', channel }));
    }
  }
}

Explicación

  • WebSocket API Gateway: gestiona el handshake WebSocket, mantiene las conexiones abiertas y enruta los mensajes entrantes a Lambda basándose en la route_selection_expression. Las rutas $connect y $disconnect son gestionadas por el sistema.
  • Persistencia de conexiones: Esto es necesario porque las funciones Lambda son stateless — no pueden mantener referencias de conexión en memoria.
  • Broadcasting: para enviar un mensaje a todos los clientes, escanea la tabla de conexiones y llama postToConnection para cada connectionId.
  • Consideraciones de escalado: el escaneo de DynamoDB para broadcasting es aceptable para audiencias pequeñas.

Variantes

PlataformaServicio WebSocketAlmacenamiento de conexionesMejor para
AWSAPI Gateway v2DynamoDBStack serverless completo
AzureAzure Web PubSubRedis / integradoEcosistemas .NET
GCPCloud Run + Socket.ioFirestoreTiempo real basado en contenedores
PusherPusher ChannelsGestionadoPrototipado rápido
AblyAbly PlatformGestionadoEscala empresarial

Lo que funciona

  • Usa salas o canales: en lugar de transmitir a todas las conexiones, agrúpalas por tema, sala o usuario. Consulta solo las conexiones relevantes para reducir costos de DynamoDB y latencia.
  • Maneja conexiones obsoletas: las conexiones pueden caer sin disparar $disconnect.
  • Habilita logging de CloudWatch: registra $connect, $disconnect e invocaciones de rutas personalizadas para debugging y monitoreo de salud de conexiones.
  • Asegura la conexión: valida tokens de autenticación en la ruta $connect usando authorizers Lambda o lógica personalizada antes de permitir que el handshake WebSocket se complete.
  • Implementa lógica de reconexión: los clientes deberían reconectarse automáticamente con backoff exponencial si la conexión cae, resuscribiéndose a canales previos al reconectar.
  • Usa TTLs de conexión: setea un atributo TTL en los registros de conexión de DynamoDB para auto-expirar conexiones obsoletas incluso si $disconnect no se dispara.
  • Batchea operaciones de DynamoDB: cuando transmitas a muchas conexiones, usa BatchWriteItem para limpieza y llamadas paralelas postToConnection con concurrencia controlada.

Errores comunes

  • Almacenar estado de conexión en memoria Lambda: las instancias Lambda son efímeras. Cualquier mapa de conexiones en memoria se pierde cuando el contenedor de la función se destruye.
  • Escanear DynamoDB para audiencias grandes: un escaneo completo de tabla en miles de conexiones es lento y costoso.
  • Olvidar manejar errores postToConnection 410: cuando un cliente se desconecta abruptamente, postToConnection arroja un error 410.
  • No configurar route_selection_expression de API Gateway: sin $request. body. action, las rutas personalizadas como sendMessage no se evaluarán y los mensajes retornarán 400.
  • Sin mecanismo de heartbeat: las conexiones inactivas se desconectan después de 10 minutos. Sin mensajes ping del lado del cliente, las conexiones caen silenciosamente y los usuarios dejan de recibir actualizaciones.
  • Transmitir a todas las conexiones por cada mensaje: no todos los mensajes necesitan llegar a todos los clientes.
  • Sin manejo de errores en Lambda para rutas desconocidas: los mensajes con acciones que no coinciden con ninguna ruta retornan 400.

Referencia Rápida

  • Comando principal: ejecuta la solución base del artículo y verifica el resultado esperado.
  • Validación: confirma que los tests pasan y que las métricas clave no se degradaron.
  • Rollback: si algo falla, revierte el cambio y consulta la sección de Troubleshooting.

Lectura Adicional

  • Documentación oficial: consulta la referencia actualizada del framework o herramienta utilizada.
  • Guías relacionadas: explora las guías de serverless y websockets para profundizar.
  • Patrones complementarios: revisa los patrones de diseño aplicables a tu stack tecnológico.
  • Postmortems públicos: estudia incidentes reales de equipos que enfrentaron problemas similares en producción.

Notas de Producción

  • Despliega gradualmente usando canary o blue-green para detectar regresiones temprano.
  • Configura alertas para errores, latencia p99 y tasa de fallos antes de habilitar en producción.
  • Documenta el rollback en el runbook; prueba el procedimiento en staging al menos una vez por trimestre.
  • Revisa logs estructurados con correlation IDs para trazar requests end-to-end en incidentes.

Puntos Clave

  • Aplica construir apis en tiempo real con websockets en serverless cuando necesites una solución práctica para tu caso de uso.
  • Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
  • Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
  • Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.

Troubleshooting

  • Cold start latency is high: increase provisioned concurrency, reduce package size, and avoid initializing heavy clients per invocation.
  • Function times out: check downstream dependencies, memory allocation, and retry logic. Increase timeout only after optimizing the code.
  • State lost between invocations: serverless functions are stateless. Persist state in a database, cache, or durable queue.
  • Deployment package too large: exclude dev dependencies and unused assets.
  • Event ordering issues: many event sources are at-least-once and unordered. Design for idempotency and explicit sequencing.

Errores Comunes en Producción

  • Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
  • Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
  • Codificar valores fijos que deberían ser configurables por entorno.
  • Olvidar agregar logging y monitoreo en cada paso.
  • Desplegar sin plan de rollback ni estrategia de backup probada.
  • Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
  • No documentar la versión y configuración usadas en producción.
  • Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.

Preguntas frecuentes

¿Esta solución está lista para producción?

Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.

¿Cuáles son las características de rendimiento?

El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.

¿Cómo depuro problemas con este enfoque?

Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.

¿Cómo pruebo localmente antes de desplegar?

Usa sam local start-api con AWS SAM para emular API Gateway WebSockets localmente. Para DynamoDB, ejecuta DynamoDB Local en Docker. Crea un cliente de prueba en JavaScript que conecte al endpoint local y envíe mensajes. Verifica que el callback $connect registre la conexión en DynamoDB y que $default procese mensajes correctamente. Para pruebas de carga, usa Artillery con el engine de WebSocket para simular cientos de conexiones concurrentes.