Arquitectura orientada a eventos en el frontend: 5 problemas
Arquitectura orientada a eventos desde el frontend: consistencia eventual, UI optimista, eventos fuera de orden, reconexión SSE y schemas. Cómo evitarlos.

Pulsas "Cancelar pedido". El botón responde, sale un toast verde, la API devuelve 202 Accepted. Recargas la lista para comprobarlo y el pedido sigue ahí, en estado "Pagado", como si no hubieras hecho nada.
Vuelves a pulsar. Ahora hay dos solicitudes de cancelación en una cola que no ves. Abres un ticket de soporte. Diez segundos después, el pedido aparece cancelado.
Nadie ha roto nada. El backend migró a una arquitectura orientada a eventos y el frontend siguió programado como si cada petición devolviera la verdad al instante.
En corto: con una arquitectura orientada a eventos en el backend, el frontend deja de recibir la verdad en la respuesta HTTP: le llega tarde, desordenada y a veces incompleta. Los cinco problemas típicos son la consistencia eventual visible, la UI optimista sin reconciliación, los eventos fuera de orden o duplicados, los huecos tras una reconexión y el acoplamiento al schema del backend. Se resuelven con versión por entidad, estados de sincronización explícitos y validación en el borde.
¿Qué cambia en el frontend con una arquitectura orientada a eventos?
La arquitectura orientada a eventos es un estilo en el que los servicios se comunican publicando hechos ya ocurridos ("pedido cancelado") en lugar de llamarse entre sí, de modo que, para el frontend, cada escritura se confirma de forma asíncrona y el estado que lee converge con retraso.
Tu POST ya no significa "hecho". Significa "recibido, ya veremos". Si el backend usa CQRS, el modelo de lectura se actualiza cuando el proyector procese el evento, como explico en cómo sincronizar el read model en CQRS.
El lado servidor (idempotencia de consumidores, outbox, colas de mensajes fallidos) es otra historia. Aquí hablo de lo que le pasa a tu pantalla.
Los 5 problemas de la arquitectura orientada a eventos en el frontend
| # | Síntoma en la UI | Causa | Solución | Coste o riesgo de la solución |
|---|---|---|---|---|
| 1 | Guardo, recargo y mi cambio no está | El read model aún no ha procesado el evento | Devolver versión en la respuesta y esperar a verla (evento o polling acotado) | Más lógica de espera; hace falta un timeout con salida clara |
| 2 | La UI dice "hecho" y minutos después falló | UI optimista sin estado pending ni rollback |
Estados pending / confirmed / failed y compensación visible |
Más estados que diseñar y testear |
| 3 | Un dato vuelve a un valor antiguo | Eventos fuera de orden o duplicados por WebSocket/SSE | Versión por entidad: descartar todo evento con versión ≤ la actual | El backend tiene que emitir una versión monotónica por entidad |
| 4 | Dos pestañas muestran datos distintos | Se cayó el socket y se perdieron eventos | id: en SSE + resync con snapshot al reconectar |
Una petición extra en cada reconexión |
| 5 | Un deploy del backend rompe una pantalla | El cliente consume eventos de dominio crudos | Eventos de integración pensados para el cliente + Zod en el borde | Una capa más (BFF o traductor) que mantener |
Problema 1: consistencia eventual que el usuario sí ve
La respuesta corta: no relances la lectura a ciegas; pide al backend la versión que generó tu escritura y espera a verla.
El patrón roto es post().then(() => refetch()). Ese refetch llega antes que el proyector y lee el estado viejo. El usuario ve su cambio "deshecho".
La garantía que necesitas se llama read-your-own-writes. Para dársela desde el cliente, la respuesta del comando debe incluir algo comparable: la versión resultante de la entidad.
// La API responde 202 con la versión que asignó al aceptar el comando (el backend debe conocerla ya)
type CommandAccepted = { orderId: string; expectedVersion: number };
async function waitForVersion(
{ orderId, expectedVersion }: CommandAccepted,
read: (id: string) => Promise<{ version: number }>,
{ attempts = 5, delayMs = 400 } = {},
): Promise<boolean> {
for (let i = 0; i < attempts; i++) {
const current = await read(orderId);
if (current.version >= expectedVersion) return true;
if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
}
return false; // no ha convergido: la UI lo dice, no lo esconde
}
Si ya tienes un stream de eventos abierto, no hagas polling: espera al evento con esa versión.
Problema 2: UI optimista sin rollback
La respuesta corta: una UI optimista sin estado pending no es optimista, es mentirosa.
El debate es viejo. En el hilo de Hacker News sobre la UI optimista de Meteor, de mayo de 2015, un usuario lo planteó sin rodeos: "Isn't this just lying to the user?". Las respuestas del mismo hilo dan la salida: mostrar el cambio al instante, pero señalar cuándo está persistido, como los dos checks de WhatsApp.
En event-driven el fallo puede llegar minutos después, como evento order.cancel_rejected, con el usuario ya en otra pantalla. Necesitas un estado de sincronización por entidad, no un booleano de loading.
export type SyncState = 39;pending39; | 39;confirmed39; | 39;failed39;;
export interface OrderView {
id: string;
status: 39;paid39; | 39;cancelled39; | 39;shipped39;;
version: number;
sync: SyncState;
previous?: OrderView; // para revertir si el backend rechaza
}
pending se pinta distinto (opacidad, "Cancelando…"). failed revierte a previous y avisa con un mensaje que diga qué pasó y qué puede hacer el usuario. Eso es compensación visible: la UI admite que se adelantó.
Problema 3: eventos fuera de orden o duplicados
La respuesta corta: cada evento trae la versión de la entidad y el cliente ignora todo lo que no sea más nuevo que lo que ya tiene.
Con varios productores o reintentos, puede llegarte la versión 4 de un pedido antes que la 3. Si aplicas por orden de llegada, el pedido "vuelve" a pagado.
Un store con signals de Angular (estables desde Angular 17). La convergencia vive en apply:
import { Injectable, computed, signal } from 39;@angular/core39;;
@Injectable({ providedIn: 39;root39; })
export class OrdersStore {
private readonly byId = signal<Record<string, OrderView>>({});
readonly orders = computed(() => Object.values(this.byId()));
/** Aplica un evento confirmado del servidor. Idempotente y tolerante al desorden. */
apply(e: { orderId: string; version: number; status: OrderView[39;status39;] }) {
this.byId.update((current) => {
const prev = current[e.orderId];
if (prev && prev.version >= e.version) return current; // viejo o duplicado
return {
...current,
[e.orderId]: { id: e.orderId, status: e.status, version: e.version, sync: 39;confirmed39; },
};
});
}
/** El backend rechazó el comando: vuelve al estado anterior y márcalo como fallido. */
reject(orderId: string) {
this.byId.update((c) => {
const prev = c[orderId];
if (!prev?.previous) return c;
return { ...c, [orderId]: { ...prev.previous, sync: 39;failed39; } };
});
}
markPending(orderId: string, status: OrderView[39;status39;]) {
this.byId.update((c) => {
const prev = c[orderId];
if (!prev) return c;
return { ...c, [orderId]: { ...prev, status, sync: 39;pending39;, previous: prev.previous ?? prev } };
});
}
}
Fíjate en que el guard de versión ya cubre los duplicados de la misma entidad. El dedupe por eventId solo hace falta para eventos sin versión (notificaciones, contadores). Y si lo añades, acota el Set o crecerá sin límite en una pestaña abierta todo el día. Ojo: reject no tiene guard; en producción relaciona el rechazo con un commandId para que un cancel_rejected duplicado no revierta un intento posterior.
Para un store convergente, signals bastan (si dudas, mira cuándo usar RxJS o signals en Angular). Los servicios con estado y signals los trabajo paso a paso en el curso de Angular Moderno.
Problema 4: reconexión y huecos en el stream de eventos
La respuesta corta: el stream nunca es completo; al reconectar, pide un snapshot y fusiónalo con el mismo guard de versión.
Si el servidor envía id: en cada evento, EventSource guarda el último y, al reconectar, lo manda en la cabecera Last-Event-ID (está en la especificación de server-sent events del WHATWG) para que el servidor reenvíe lo perdido. Eso depende de que el servidor guarde histórico y de que ningún proxy se coma la cabecera. Es una optimización, no una garantía.
type OrderSnapshot = { orderId: string; version: number; status: OrderView[39;status39;] };
export function connect(store: OrdersStore, loadSnapshot: () => Promise<OrderSnapshot[]>) {
const es = new EventSource(39;/api/orders/stream39;);
// 'open' se dispara en la conexión inicial y en cada reconexión
es.addEventListener(39;open39;, async () => {
const snapshot = await loadSnapshot(); // estado actual + versión de cada pedido
snapshot.forEach((o) => store.apply(o)); // el guard descarta lo que ya es viejo
});
// Solo llegan aquí los mensajes enviados con "event: order"; sin ese campo van a 'message'
es.addEventListener(39;order39;, (msg: MessageEvent<string>) => {
const event = parseOrderEvent(msg.data); // ver problema 5
if (!event) return;
if (event.type === 39;order.status_changed39;) store.apply(event);
else store.reject(event.orderId); // order.cancel_rejected
});
es.addEventListener(39;error39;, () => {
// CONNECTING: el navegador reintenta solo. CLOSED: no volverá; reconecta tú (sin Last-Event-ID: el snapshot cubre el hueco).
if (es.readyState === EventSource.CLOSED) {
setTimeout(() => connect(store, loadSnapshot), 3000);
}
});
return es;
}
Snapshot y eventos pasan por el mismo apply. Da igual qué llegue primero: gana la versión más alta.
Problema 5: acoplar la UI al schema interno del backend
La respuesta corta: el frontend no debe consumir eventos de dominio crudos; necesita eventos de integración estables y validados al entrar.
Los eventos de dominio cambian cuando cambia el modelo interno del servicio. Si tu componente lee event.payload.aggregate.lines[0].sku, cualquier refactor del backend rompe la pantalla en producción, sin error de compilación.
Dos capas: un BFF o traductor que publique eventos pensados para el cliente (planos, versionados, solo lo que la UI pinta) y validación en el borde con Zod 4, para que un mensaje inesperado se descarte en vez de corromper el store.
import { z } from 39;zod39;;
const OrderEvent = z.discriminatedUnion(39;type39;, [
z.object({
type: z.literal(39;order.status_changed39;),
eventId: z.string(),
orderId: z.string(),
version: z.number().int().nonnegative(),
status: z.enum([39;paid39;, 39;cancelled39;, 39;shipped39;]),
}),
z.object({
type: z.literal(39;order.cancel_rejected39;),
eventId: z.string(),
orderId: z.string(),
version: z.number().int().nonnegative(),
reason: z.string(),
}),
]);
export type OrderEventData = z.infer<typeof OrderEvent>;
export function parseOrderEvent(raw: string): OrderEventData | null {
let json: unknown;
try {
json = JSON.parse(raw);
} catch {
return null;
}
const result = OrderEvent.safeParse(json);
if (!result.success) {
console.warn(39;Evento descartado39;, result.error.issues);
return null;
}
return result.data;
}
safeParse no lanza: te devuelve success y decides tú. Con discriminatedUnion, el resto del código recibe tipos estrechos según type. Si quieres llevar este patrón a todo el estado global, lo desarrollé en gestión de estado global con Zod y signals, y los schemas, transformaciones y errores de Zod los cubro a fondo en el curso de Zod para TypeScript.
Cuándo NO usar estos patrones de arquitectura orientada a eventos en el frontend
UI optimista en pagos, stock o cualquier acción irreversible. Si el usuario va a cerrar la pestaña creyendo que pagó, el pending bonito no basta. Ahí muestra "Procesando pago" y espera a la confirmación. El propio hilo de HN citado arriba separa preferencias de interfaz (optimismo sin problema) de facturación o mensajería (cuidado).
Tiempo real cuando un refetch basta. Un panel de administración que se consulta cinco veces al día no necesita SSE ni resync. Un refetch al volver a la pestaña y un polling acotado tras cada comando resuelven casi todo con una fracción del código. Herramientas como las que comparo en httpResource vs TanStack Query ya traen revalidación al enfocar.
Sin versión monotónica del backend, el guard no sirve. Comparar updatedAt entre máquinas con relojes distintos no es una versión. Si el backend no puede emitir una versión por entidad, negocia eso antes de escribir una línea de cliente.
El BFF no es gratis. Para un solo frontend y un equipo pequeño, un mapper tipado en el cliente con Zod puede ser suficiente. El BFF compensa cuando hay varios clientes o el backend cambia más rápido que la UI.
SSE sin HTTP/2 y con muchas pestañas. Según MDN, sin HTTP/2 el navegador limita a 6 las conexiones abiertas por dominio, y Chrome y Firefox lo marcaron como "Won't fix". Con siete pestañas de tu app, la séptima se queda colgada. Sirve con HTTP/2 o con una sola conexión compartida entre pestañas.
Lo que puedes hacer hoy
Abre el servicio que recibe eventos en tu app y busca una cosa: ¿cada mensaje trae versión por entidad y tu store la compara antes de aplicar? Si la respuesta es no, ese es tu primer cambio. Es la pieza que hace posibles las otras cuatro.
Después, escribe el test que casi nadie escribe: aplica el evento versión 4, luego el versión 3, y comprueba que el estado sigue en 4. Si quieres montar esa batería con Jest y Testing Library en Angular, el curso de Testing en Angular cubre cómo testear servicios con estado y componentes que reaccionan a él.
Preguntas frecuentes
¿La arquitectura orientada a eventos obliga a usar WebSockets en el frontend?
No. El frontend puede seguir con HTTP y polling acotado por versión. WebSockets o SSE compensan cuando el usuario necesita ver cambios que no ha provocado él.
¿SSE o WebSockets para recibir eventos del backend?
Si el flujo es solo del servidor al cliente, SSE es más simple: va sobre HTTP, reconecta solo y, si el servidor manda id: en cada evento, envía Last-Event-ID sin que escribas código. WebSockets tienen sentido cuando el cliente también emite muchos mensajes por el mismo canal, como en un chat o en edición colaborativa.
¿Cómo evito que un evento antiguo sobrescriba uno nuevo en el cliente?
Pide al backend una versión monotónica por entidad en cada evento. En el store, descarta cualquier evento cuya versión sea menor o igual que la que ya tienes. Ese mismo guard resuelve también los duplicados de la misma entidad.
¿Qué hago si se pierden eventos mientras el socket estaba caído?
Asume que siempre puede pasar. Al reconectar, pide un snapshot del estado con la versión de cada entidad y fusiónalo con el mismo guard que usas para los eventos. Last-Event-ID ayuda a recuperar lo perdido, pero depende de que el servidor guarde histórico.
¿Es mala práctica la UI optimista en una arquitectura orientada a eventos?
No, siempre que tenga estados explícitos (pending, confirmed, failed) y una forma visible de revertir. Es mala práctica cuando la UI declara éxito antes de la confirmación en operaciones irreversibles, como pagos.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Quieres construir con agentes en serio?
Curso «Construye con IA» con Claude Code, de la idea al producto.
Developer con más de 15 años en frontend y creador de Dominicode. Escribe sobre IA aplicada al desarrollo de software.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.