En septiembre de 2025, Nudato procesó más de 50.000 notificaciones en tiempo real durante un solo evento de 3 días. Nuestro pipeline original no estaba preparado. Esta es la historia de cómo lo reconstruimos, los errores que cometimos en el camino, y las lecciones que aplicamos ahora en cada proyecto.
Contexto: Qué es Nudato y por qué importa el tiempo real
Nudato es una plataforma de gestión de eventos y conferencias que desarrollamos internamente. Los organizadores la usan para gestionar agendas, registros, networking, y comunicación con asistentes. Los asistentes reciben notificaciones en tiempo real sobre cambios de sala, horario, mensajes del organizador, y alertas de networking ("alguien con intereses similares está cerca").
El tiempo real no es un nice-to-have en este contexto. Si un keynote cambia de sala y 2.000 asistentes no se enteran en los próximos 30 segundos, tienes un problema logístico real: gente en la sala equivocada, organizadores estresados, y una experiencia terrible.
El problema
El sistema original de notificaciones de Nudato era simple y honesto: cuando ocurría un evento (nueva inscripción, cambio de sala, mensaje del organizador), un worker procesaba la notificación y la enviaba por WebSocket al cliente.
La arquitectura se veía así:
┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ Client │────▶│ API │────▶│ PostgreSQL │────▶│ Worker │────▶│ WebSocket│
│ (action) │ │ (write) │ │ (persist) │ │(poll 2s) │ │ (push) │
└──────────┘ └──────────┘ └──────────────┘ └──────────┘ └──────────┘
│
Bottleneck:
workers poll cada 2s,
procesan secuencialmente
Funcionaba bien con 500 asistentes. Era simple de entender, simple de debuggear, y el coste era un par de instancias EC2. No teníamos razones para complicarlo.
Con 5.000 asistentes en una conferencia de septiembre de 2025, empezamos a ver problemas serios:
- Latencia de 8-15 segundos en notificaciones. Un cambio de sala tardaba 15 segundos en llegar a los asistentes. En una conferencia con sesiones de 30 minutos, eso es inaceptable.
- Pérdida de mensajes durante picos de actividad. Cuando un organizador enviaba un mensaje a todos los asistentes al mismo tiempo, el worker no daba abasto y las conexiones de base de datos se saturaban. Estimamos que perdíamos ~2% de los mensajes en picos.
- Workers saturados que bloqueaban el procesamiento de otras tareas. El worker de notificaciones compartía resources con el procesamiento de registros y la generación de badges. Cuando las notificaciones piceaban, los registros se ralentizaban.
- WebSocket connections dropping. Nuestro servidor WebSocket corría en una sola instancia EC2. Con 5.000 conexiones concurrentes, el memory y los file descriptors eran un problema.
El diagnóstico fue claro: necesitábamos desacoplar la ingesta de eventos del procesamiento y la entrega.
La solución: Event-driven con AWS
Rediseñamos el pipeline usando una arquitectura event-driven pura con servicios managed de AWS. La decisión de usar managed services fue deliberada: no queríamos operar Kafka o RabbitMQ nosotros mismos. Para nuestro volumen (miles, no millones, de eventos por segundo), los servicios managed de AWS eran más que suficientes y eliminaban el overhead operacional.
Arquitectura nueva
┌──────────┐ ┌──────────┐ ┌───────────────┐
│ Client │────▶│ API │────▶│ EventBridge │
│ (action) │ │ (emit) │ │ (event bus) │
└──────────┘ └──────────┘ └───────┬───────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ SQS High │ │ SQS Normal │ │ DynamoDB │
│ Priority │ │ Priority │ │ (persist)│
└────┬─────┘ └──────┬───────┘ └──────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ Lambda │ │ Lambda │
│(inmedia.)│ │ (batch 30s) │
└────┬─────┘ └──────┬───────┘
│ │
▼ ▼
┌─────────────────────────────┐
│ API Gateway WebSocket │
│ (managed connections) │
└─────────────────────────────┘
Componentes clave en detalle
1. Amazon EventBridge como bus de eventos central.
Cada acción en la plataforma emite un evento tipado a EventBridge. No solo notificaciones — todo: registros, check-ins, feedback, cambios de agenda, mensajes. EventBridge es el "sistema nervioso" que conecta todas las partes.
La ventaja de EventBridge sobre SQS directo o SNS es el content-based routing: puedes enrutar eventos a diferentes targets según el contenido del evento, sin que el productor sepa quién lo consume. Esto nos permite agregar nuevos consumidores (analytics, audit log, integraciones) sin tocar el código que emite eventos.
// Emitir un evento desde la API
// src/events/publisher.ts
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
const eventBridge = new EventBridgeClient({ region: 'us-east-1' });
interface NudatoEvent {
type: string;
conferenceId: string;
priority: 'high' | 'normal' | 'low';
payload: Record<string, unknown>;
metadata: {
userId: string;
timestamp: string;
correlationId: string;
};
}
export async function publishEvent(event: NudatoEvent): Promise<void> {
const result = await eventBridge.send(new PutEventsCommand({
Entries: [{
Source: 'nudato.platform',
DetailType: event.type,
Detail: JSON.stringify({
conferenceId: event.conferenceId,
priority: event.priority,
payload: event.payload,
metadata: {
...event.metadata,
timestamp: new Date().toISOString(),
version: '2.0', // Schema versioning
},
}),
EventBusName: 'nudato-events',
}],
}));
// Verificar que el evento se publicó correctamente
if (result.FailedEntryCount && result.FailedEntryCount > 0) {
console.error('Failed to publish event:', result.Entries);
// Retry logic o fallback a SQS directo
throw new EventPublishError(event.type, result.Entries);
}
}
// Uso desde un endpoint de la API
// src/routes/agenda.ts
export async function updateSessionRoom(req: Request, res: Response) {
const { sessionId, newRoomId } = req.body;
const correlationId = crypto.randomUUID();
// 1. Actualizar en la base de datos
const session = await sessionRepo.updateRoom(sessionId, newRoomId);
// 2. Emitir evento (fire-and-forget para la API, procesado async)
await publishEvent({
type: 'session.room-changed',
conferenceId: session.conferenceId,
priority: 'high', // Cambio de sala es urgente
payload: {
sessionId,
sessionTitle: session.title,
oldRoomId: session.previousRoomId,
newRoomId,
newRoomName: session.room.name,
startsAt: session.startsAt,
},
metadata: {
userId: req.user.id,
timestamp: new Date().toISOString(),
correlationId,
},
});
res.json({ success: true, session });
}
2. SQS Queues separadas por prioridad.
Este fue uno de los diseños más impactantes. No todas las notificaciones son iguales:
- Alta prioridad: cambio de sala, alerta de emergencia, cancelación de sesión. El asistente debe recibirla en menos de 1 segundo.
- Prioridad normal: nuevo mensaje del organizador, recordatorio de sesión. Puede batched en ventanas de 30 segundos.
- Baja prioridad: nuevo asistente registrado, feedback recibido, sugerencia de networking. Puede batched en ventanas de 5 minutos.
Separar las queues por prioridad significa que un pico de notificaciones de baja prioridad (por ejemplo, 500 registros simultáneos cuando se abren las inscripciones) nunca retrasa la entrega de un cambio de sala urgente.
3. Lambda functions especializadas.
Cada Lambda procesa un tipo de notificación de forma optimizada. La Lambda de alta prioridad no hace batching — procesa cada mensaje individualmente tan rápido como puede. La Lambda de prioridad normal agrupa mensajes en ventanas de 30 segundos para reducir el número de pushes al cliente.
// Lambda para notificaciones de alta prioridad
// functions/notify-high-priority/handler.ts
import { SQSHandler, SQSRecord } from 'aws-lambda';
import { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { getActiveConnections } from './connections';
const wsApi = new ApiGatewayManagementApi({
endpoint: process.env.WEBSOCKET_ENDPOINT,
});
export const handler: SQSHandler = async (event) => {
const results = await Promise.allSettled(
event.Records.map(record => processHighPriorityNotification(record))
);
// Log failures pero no re-throw para evitar re-procesamiento
// de mensajes ya entregados (SQS batch semantics)
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.error(`${failures.length}/${results.length} notifications failed`);
// Las fallidas van a DLQ automáticamente vía SQS config
}
};
async function processHighPriorityNotification(record: SQSRecord) {
const event = JSON.parse(record.body);
const { conferenceId, payload, metadata } = event.detail;
// 1. Obtener conexiones WebSocket activas para esta conferencia
const connections = await getActiveConnections(conferenceId);
// 2. Formatear la notificación según el tipo
const notification = formatNotification(event);
// 3. Enviar a todos los clientes conectados en paralelo
const sendPromises = connections.map(async (conn) => {
try {
await wsApi.postToConnection({
ConnectionId: conn.connectionId,
Data: Buffer.from(JSON.stringify(notification)),
});
} catch (error: any) {
if (error.statusCode === 410) {
// Conexión cerrada, limpiar
await removeConnection(conn.connectionId);
} else {
throw error;
}
}
});
await Promise.allSettled(sendPromises);
// 4. También enviar push notification a dispositivos móviles
// para usuarios que no están conectados por WebSocket
const offlineUsers = await getOfflineUsers(conferenceId, connections);
if (offlineUsers.length > 0) {
await sendPushNotifications(offlineUsers, notification);
}
}
4. API Gateway WebSocket para conexiones persistentes.
Reemplazamos nuestro servidor WebSocket custom por API Gateway WebSocket. La diferencia operacional es enorme:
- Escalado automático de 0 a 50.000+ conexiones concurrentes sin configurar nada
- No gestionamos servidores — AWS maneja los WebSocket connections, heartbeats, y connection limits
- DynamoDB como connection store — cuando un cliente se conecta, guardamos su connectionId en DynamoDB con TTL. Cuando necesitamos enviar un mensaje, consultamos las conexiones activas para esa conferencia
// WebSocket connect handler
// functions/ws-connect/handler.ts
import { APIGatewayProxyHandler } from 'aws-lambda';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
export const handler: APIGatewayProxyHandler = async (event) => {
const connectionId = event.requestContext.connectionId;
const conferenceId = event.queryStringParameters?.conferenceId;
const userId = event.requestContext.authorizer?.userId;
if (!conferenceId || !userId) {
return { statusCode: 400, body: 'Missing conferenceId or auth' };
}
// Guardar conexión con TTL de 24 horas
await dynamoDb.send(new PutCommand({
TableName: process.env.CONNECTIONS_TABLE,
Item: {
pk: `CONF#${conferenceId}`,
sk: `CONN#${connectionId}`,
connectionId,
userId,
conferenceId,
connectedAt: new Date().toISOString(),
ttl: Math.floor(Date.now() / 1000) + 86400, // 24h TTL
},
}));
return { statusCode: 200, body: 'Connected' };
};
El patrón que más nos ayudó: Fan-out con filtros
EventBridge permite enrutar eventos a diferentes targets según su contenido. Esto nos dio la flexibilidad de procesar cada tipo de notificación de forma óptima sin que el productor sepa nada sobre los consumidores.
// Regla de EventBridge para notificaciones urgentes
// infrastructure/rules.ts (CDK)
const highPriorityRule = new events.Rule(this, 'HighPriorityRule', {
eventBus: nudatoBus,
eventPattern: {
source: ['nudato.platform'],
detailType: ['session.room-changed', 'emergency.alert', 'session.cancelled'],
detail: {
priority: ['high'],
},
},
});
highPriorityRule.addTarget(new targets.SqsQueue(highPriorityQueue));
// Regla para notificaciones normales
const normalPriorityRule = new events.Rule(this, 'NormalPriorityRule', {
eventBus: nudatoBus,
eventPattern: {
source: ['nudato.platform'],
detailType: ['organizer.message', 'session.reminder'],
detail: {
priority: ['normal'],
},
},
});
normalPriorityRule.addTarget(new targets.SqsQueue(normalPriorityQueue, {
// Batching: esperar hasta 30 segundos o 10 mensajes
// Esto reduce invocaciones Lambda y permite agrupar notificaciones
}));
// Regla para persistencia (TODOS los eventos van a DynamoDB)
const persistRule = new events.Rule(this, 'PersistRule', {
eventBus: nudatoBus,
eventPattern: {
source: ['nudato.platform'],
},
});
persistRule.addTarget(new targets.LambdaFunction(persistFunction));
// Regla para analytics (nuevo consumidor, sin tocar productores)
const analyticsRule = new events.Rule(this, 'AnalyticsRule', {
eventBus: nudatoBus,
eventPattern: {
source: ['nudato.platform'],
},
});
analyticsRule.addTarget(new targets.KinesisFirehose(analyticsFirehose));
Lo poderoso de este patrón es que agregar un nuevo consumidor no requiere cambiar nada en el productor. Cuando agregamos analytics 3 meses después, simplemente creamos una nueva regla de EventBridge que enruta todos los eventos a un Kinesis Firehose que los deposita en S3 para procesarlos con Athena. El código que emite eventos no cambió ni una línea.
Monitoring y observabilidad
Una arquitectura event-driven sin observabilidad es como volar a ciegas. No puedes debuggear problemas si no ves qué pasa entre los servicios. Este fue un aprendizaje doloroso — montamos la observabilidad después de la migración y nos costó semanas de problemas difíciles de diagnosticar.
Si pudiéramos hacerlo de nuevo, montaríamos el monitoring antes de migrar un solo evento.
Lo que monitoreamos
Dashboard 1: Event Flow (CloudWatch)
Métricas clave:
- Events emitidos por tipo (EventBridge PutEvents)
- Events procesados por queue (SQS NumberOfMessagesReceived)
- Lambda invocations y errors por función
- WebSocket connections activas (DynamoDB item count)
- DLQ message count (ALERTA si > 0)
Dashboard 2: Latencia end-to-end (custom metric)
Cada evento lleva un timestamp de creación. Cuando la Lambda lo procesa, calcula la diferencia y la emite como custom metric a CloudWatch. Esto nos da la latencia real que experimenta el usuario.
// Medir latencia end-to-end en cada Lambda
// shared/metrics.ts
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
const cloudwatch = new CloudWatchClient({});
export async function trackEventLatency(
eventType: string,
priority: string,
eventTimestamp: string,
) {
const latencyMs = Date.now() - new Date(eventTimestamp).getTime();
await cloudwatch.send(new PutMetricDataCommand({
Namespace: 'Nudato/Events',
MetricData: [{
MetricName: 'EventProcessingLatency',
Value: latencyMs,
Unit: 'Milliseconds',
Dimensions: [
{ Name: 'EventType', Value: eventType },
{ Name: 'Priority', Value: priority },
],
Timestamp: new Date(),
}],
}));
// Alerta inmediata si high-priority > 2 segundos
if (priority === 'high' && latencyMs > 2000) {
console.error(`HIGH LATENCY ALERT: ${eventType} took ${latencyMs}ms`);
// Esto triggerea una CloudWatch Alarm → SNS → PagerDuty
}
}
Dashboard 3: Dead Letter Queues
Configuramos una alarma que nos notifica en Slack si cualquier DLQ recibe un mensaje. Un mensaje en DLQ significa que algo falló después de todos los retries, y necesita atención humana.
// Alarma para DLQ (CDK)
const dlqAlarm = new cloudwatch.Alarm(this, 'DLQAlarm', {
metric: highPriorityDLQ.metricApproximateNumberOfMessagesVisible(),
threshold: 1,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
alarmDescription: 'Messages in high-priority DLQ - requires investigation',
});
dlqAlarm.addAlarmAction(new actions.SnsAction(alertsTopic));
Structured logging con correlationId
Cada evento lleva un correlationId que se propaga a través de todo el pipeline. Si un usuario reporta "no me llegó la notificación del cambio de sala", podemos buscar por correlationId en CloudWatch Logs Insights y trazar exactamente qué pasó:
-- CloudWatch Logs Insights query
-- Trazar un evento desde emisión hasta entrega
fields @timestamp, @message
| filter correlationId = "abc-123-def-456"
| sort @timestamp asc
| limit 50
-- Resultado típico:
-- 10:00:00.100 [api] Event emitted: session.room-changed (correlationId: abc-123)
-- 10:00:00.250 [eventbridge] Event matched rule: HighPriorityRule
-- 10:00:00.310 [sqs] Message enqueued to high-priority-queue
-- 10:00:00.450 [lambda] Processing notification for conference conf-789
-- 10:00:00.520 [lambda] Found 1,247 active WebSocket connections
-- 10:00:00.680 [lambda] Sent to 1,245 connections, 2 stale (removed)
-- 10:00:00.700 [lambda] Sent push to 312 offline users
Resultados
Después del rediseño, los números hablan solos:
| Métrica | Antes | Después | Mejora | |---------|-------|---------|--------| | Latencia P50 | 4s | 80ms | 50x | | Latencia P95 | 8-15s | 120ms | 100x | | Latencia P99 | 20s+ | 350ms | 57x | | Mensajes perdidos | ~2% en picos | 0% | Eliminado | | Conexiones concurrentes | ~2K (límite EC2) | ~50K (API Gateway) | 25x | | Coste mensual | $340 (EC2 workers 24/7) | $45 (Lambda pay-per-use) | -87% | | Tiempo de recuperación ante fallos | Manual (minutos) | Automático (retry + DLQ) | N/A | | Tiempo para agregar nuevo consumidor | 2-3 días | 2-3 horas | 10x |
Lo más impactante fue el coste: al pasar de workers EC2 que corrían 24/7 a Lambda que solo se ejecuta cuando hay eventos, redujimos el coste un 87%. Entre conferencias (cuando no hay eventos activos), el coste de infraestructura del pipeline de notificaciones es literalmente $0 porque no hay Lambdas ejecutándose ni conexiones WebSocket activas.
El segundo impacto más importante fue la capacidad de agregar nuevos consumidores sin tocar el código existente. Cuando el equipo de producto pidió analytics en tiempo real ("quiero ver cuántas personas leyeron la notificación del cambio de sala"), agregamos un nuevo target en EventBridge en una tarde. Sin deployar nuevas versiones del API ni del pipeline de notificaciones.
Lecciones aprendidas (las que duelen)
1. No todo necesita ser event-driven
Las lecturas y escrituras CRUD simples siguen siendo request-response. Solo movimos a eventos lo que realmente se beneficia de procesamiento asíncrono. Intentamos mover los registros de asistentes a event-driven y fue un error — el usuario espera una respuesta inmediata de "te registraste correctamente", y agregar un paso asíncrono en medio solo complicó la UX sin beneficio real.
Regla práctica: si el usuario necesita confirmación inmediata de que algo pasó, request-response. Si el procesamiento puede ocurrir en background sin que el usuario espere, event-driven.
2. Los dead-letter queues son obligatorios, no opcionales
En producción, siempre habrá mensajes que fallan. Un JSON malformado, un timeout de Lambda, un servicio downstream caído. Sin DLQ, los pierdes silenciosamente. Con DLQ, los capturas, los investigas, y puedes re-procesarlos.
Nuestra DLQ salvó la situación en un incidente donde una Lambda tenía un bug que fallaba con eventos que tenían caracteres Unicode en el título de la sesión. Sin DLQ, habríamos perdido ~150 notificaciones. Con DLQ, las re-procesamos después de fixear el bug.
3. Schema versioning desde el día uno
Los eventos son contratos entre servicios. Cuando cambias la estructura de un evento, todos los consumidores deben poder manejar la versión vieja y la nueva. Aprendimos esto de la manera difícil cuando agregamos un campo obligatorio y rompimos una Lambda que procesaba eventos viejos.
Ahora cada evento lleva un campo version y cada consumidor tiene lógica para manejar múltiples versiones:
function processEvent(event: NudatoEvent) {
switch (event.metadata.version) {
case '1.0':
return processV1(event);
case '2.0':
return processV2(event);
default:
console.warn(`Unknown event version: ${event.metadata.version}`);
return processV2(event); // Best effort con la versión más reciente
}
}
4. Observabilidad primero, migración después
Antes de migrar, deberías tener dashboards con métricas de latencia, throughput y errores por tipo de evento. Nosotros montamos los dashboards después de la migración y perdimos días diagnosticando problemas que habríamos detectado en minutos con métricas adecuadas.
5. Idempotencia no es negociable
En un sistema event-driven, los mensajes pueden entregarse más de una vez (SQS tiene at-least-once delivery). Cada handler debe ser idempotente — procesar el mismo mensaje dos veces debe tener el mismo resultado que procesarlo una vez.
// Patrón de idempotencia con DynamoDB
async function processNotification(messageId: string, event: NotificationEvent) {
// Intentar marcar como procesado (conditional put)
try {
await dynamoDb.send(new PutCommand({
TableName: process.env.IDEMPOTENCY_TABLE,
Item: {
pk: messageId,
processedAt: new Date().toISOString(),
ttl: Math.floor(Date.now() / 1000) + 86400, // 24h
},
ConditionExpression: 'attribute_not_exists(pk)',
}));
} catch (error: any) {
if (error.name === 'ConditionalCheckFailedException') {
console.log(`Message ${messageId} already processed, skipping`);
return; // Ya procesado, skip
}
throw error;
}
// Procesar normalmente
await deliverNotification(event);
}
6. Testear eventos es diferente a testear APIs
No puedes hacer un curl a un evento. Necesitas herramientas específicas para testear pipelines event-driven:
- Tests unitarios para cada Lambda handler con eventos mockeados
- Tests de integración que publican un evento real en un EventBridge de staging y verifican que llega al destino
- Tests de contrato que validan que el schema del evento emitido coincide con lo que el consumidor espera
- Chaos testing que inyecta fallos (Lambda timeouts, SQS delays) para verificar que DLQs y retries funcionan
El mejor sistema distribuido es el que no necesitas. Pero cuando lo necesitas, hazlo bien desde el principio — con observabilidad, idempotencia, dead-letter queues, y schema versioning. El coste de agregar estas cosas después es 10x mayor que hacerlo desde el inicio.
Cuándo NO usar event-driven
Para balancear este artículo, estas son las situaciones donde event-driven es over-engineering:
- CRUD simple. Si tu app es un formulario que guarda datos y los muestra en una tabla, request-response es perfecto.
- Equipo de 1-2 devs. El overhead cognitivo de entender y debuggear un sistema event-driven no vale la pena si el equipo es muy pequeño.
- Volúmenes bajos. Si procesas menos de 100 eventos por minuto, un worker simple con polling es más que suficiente.
- Cuando la latencia del procesamiento no importa. Si está bien que un email de bienvenida llegue en 30 segundos o en 5 minutos, un cron job que procesa una cola cada minuto es más simple y funciona igual.
La complejidad tiene un coste. Agrégala solo cuando el problema lo justifique.
¿Tu sistema necesita escalar para manejar picos de tráfico? Hablemos sobre cómo diseñar una arquitectura event-driven que aguante el crecimiento sin romper el banco.