La lección anterior dejó Nómada Tareas conectada y robusta, pero con un límite que ninguna mejora de fetch resuelve: el navegador solo se entera de las cosas cuando pregunta. Si Iván arrastra una tarea a «en curso» desde el taller, el tablero de Marta seguirá mostrando el estado antiguo hasta que ella refresque. Preguntar cada cinco segundos es malgastar batería y ancho de banda para oír «nada nuevo» el 95 % de las veces. Lo que hace falta es invertir la iniciativa: que el servidor pueda hablar primero. Eso es un WebSocket, una conexión permanente y bidireccional por la que los mensajes viajan en ambos sentidos en cuanto ocurren. En esta lección compararás las cuatro técnicas de tiempo real, entenderás el handshake que convierte una petición HTTP en un canal persistente, dominarás la API WebSocket del navegador, diseñarás un pequeño protocolo de mensajes, resolverás la reconexión y los conflictos de edición simultánea, y escribirás js/datos/tiempo-real.js con la clase CanalTablero.

Contenido

  1. Cuando petición-respuesta no basta
  2. Las cuatro técnicas de tiempo real
  3. El protocolo: del handshake al canal persistente
  4. La API WebSocket del navegador
  5. readyState y el ciclo de vida
  6. Enviar y recibir: send, message y JSON
  7. Un protocolo de mensajes para Nómada Tareas
  8. Cerrar bien: close(codigo, motivo)
  9. Reconexión con retroceso exponencial
  10. Heartbeat: detectar conexiones zombis
  11. Encolar mensajes mientras no hay conexión
  12. Integrar con los CustomEvent de la vista
  13. Conflictos: dos personas, una misma tarea
  14. Seguridad: wss, autenticación y validación
  15. Nómada Tareas: js/datos/tiempo-real.js
  16. Un servidor de pruebas mínimo
  17. Errores Comunes y Consejos
  18. Ejercicios
  19. Conclusión

  1. Cuando petición-respuesta no basta

HTTP es un protocolo de petición y respuesta: el cliente pregunta, el servidor contesta, la conexión termina. Ese modelo es perfecto para cargar una página o guardar un formulario, y es estructuralmente incapaz de hacer una cosa: que el servidor avise por iniciativa propia.

Los casos donde eso duele son reconocibles:

  • Un tablero compartido donde tres personas mueven tarjetas a la vez.
  • Un chat, donde esperar cinco segundos a que aparezca un mensaje es inaceptable.
  • Un panel de métricas en vivo, notificaciones, cotizaciones, una partida multijugador.
  • Un editor colaborativo, donde ves el cursor de la otra persona.

En Nómada Tareas el escenario es concreto: Marta tiene el tablero abierto en la pantalla grande de la sala. Iván marca desde el taller la tarea «Presupuesto de la carpintería» como en curso. Lo correcto es que la tarjeta se mueva en la pantalla de Marta, sola, en menos de un segundo, sin que nadie pulse nada.

  1. Las cuatro técnicas de tiempo real

Antes de elegir WebSockets conviene saber qué alternativas hay, porque a menudo una más simple basta.

Técnica Cómo funciona Dirección Coste Cuándo elegirla
Polling El cliente pregunta cada N segundos Cliente → servidor Alto: muchas peticiones vacías Datos que cambian poco y la latencia no importa (métricas cada minuto)
Long polling El cliente pregunta y el servidor retiene la respuesta hasta que haya algo Cliente → servidor Medio Compatibilidad máxima, sin infraestructura especial
SSE (EventSource) Una conexión HTTP que el servidor mantiene abierta y por la que envía eventos Servidor → cliente (unidireccional) Bajo Notificaciones, avisos, un panel que solo recibe
WebSockets Conexión persistente full-duplex sobre TCP Ambos sentidos Bajo por mensaje, requiere servidor con estado Chat, colaboración, juegos, tableros compartidos

Dos comparaciones que conviene tener claras:

Polling frente a WebSocket, en números. Un polling cada 5 segundos son 720 peticiones por hora y usuario. Cada una arrastra cabeceras HTTP completas (cookies, User-Agent, Accept…): fácilmente 1 KB de ida y otro de vuelta aunque no haya novedades. Eso es más de 1 MB por hora por persona para no decir nada. Un WebSocket paga el coste del handshake una vez y luego cada mensaje son unos pocos bytes de cabecera de trama.

SSE frente a WebSocket. SSE se infravalora. Es HTTP normal —atraviesa proxies sin problemas—, se reconecta solo y es trivial de implementar en el servidor. Su limitación es que el cliente no puede enviar por el mismo canal: para eso usa fetch aparte.

// Server-Sent Events: sorprendentemente simple para recibir
const fuente = new EventSource('https://api.tallernomada.example/v1/eventos');

fuente.addEventListener('tarea:actualizada', (evento) => {
  const tarea = JSON.parse(evento.data);
  vista.actualizarTarjeta(tarea);
});

fuente.onerror = () => console.warn('SSE reconectando solo…');   // ← lo hace el navegador

La regla de decisión: si solo recibes, SSE. Si además envías con frecuencia y necesitas baja latencia en ambos sentidos, WebSockets. Nómada Tareas está en el segundo caso: Marta e Iván no solo miran, también mueven tarjetas.

  1. El protocolo: del handshake al canal persistente

Un WebSocket empieza siendo una petición HTTP. Esa es la clave de su diseño: reutiliza el puerto 443 y atraviesa la infraestructura existente.

sequenceDiagram
    participant C as Navegador
    participant S as Servidor

    Note over C,S: Fase 1 · Handshake (HTTP)
    C->>S: GET /tablero HTTP/1.1<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Key: dGhlIHNhbXBsZQ==<br/>Sec-WebSocket-Version: 13
    S-->>C: HTTP/1.1 101 Switching Protocols<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Accept: s3pPLMBiTxaQ…

    Note over C,S: Fase 2 · Canal persistente (ya no es HTTP)
    C->>S: {"tipo":"suscribir","carga":{"tablero":"taller-nomada"}}
    S-->>C: {"tipo":"estado:completo","carga":{…6 tareas…}}
    S-->>C: {"tipo":"tarea:actualizada","carga":{"id":6,"estado":"en-curso"}}
    C->>S: {"tipo":"tarea:actualizada","carga":{"id":2,"estado":"hecha"}}
    S-->>C: {"tipo":"pong"}

    Note over C,S: Fase 3 · Cierre
    C->>S: close(1000, "Vista cerrada")
    S-->>C: close ack

Los puntos que importan:

  • 101 Switching Protocols es el código que autoriza el cambio. Es el único 1xx que verás en tu vida como desarrollador de frontend, y ya apareció en la tabla de 07-02.
  • Tras el 101 ya no hay HTTP. No hay verbos, ni rutas, ni códigos de estado, ni cabeceras por mensaje. Solo tramas de datos en ambos sentidos.
  • Los esquemas son ws:// y wss://, análogos a http:// y https://. wss es WebSocket sobre TLS.
  • Es full-duplex: ambos extremos pueden enviar en cualquier momento, sin turnos y sin que un mensaje sea la "respuesta" de otro. Esa ausencia de correlación petición-respuesta es el mayor cambio mental respecto a fetch.
  • La conexión tiene estado. El servidor mantiene un objeto por cliente conectado, lo que complica el escalado horizontal: dos servidores detrás de un balanceador no comparten conexiones sin ayuda (Redis, un bus de mensajes…). Es un coste real que hay que conocer antes de elegir.

  1. La API WebSocket del navegador

La API es pequeña: un constructor, cuatro eventos, dos métodos y unas cuantas propiedades.

const socket = new WebSocket('wss://api.tallernomada.example/v1/tablero');

socket.addEventListener('open', () => {
  console.log('Conectado');
  socket.send(JSON.stringify({ tipo: 'suscribir', carga: { tablero: 'taller-nomada' } }));
});

socket.addEventListener('message', (evento) => {
  const mensaje = JSON.parse(evento.data);       // evento.data SIEMPRE es texto o binario
  console.log('Recibido:', mensaje.tipo);
});

socket.addEventListener('error', () => {
  console.error('Error en el socket');           // ← no dice cuál, por seguridad
});

socket.addEventListener('close', (evento) => {
  console.log('Cerrado:', evento.code, evento.reason, 'limpio:', evento.wasClean);
});
Miembro Tipo Qué es
new WebSocket(url, protocolos?) constructor Abre la conexión inmediatamente
readyState número 0 conectando, 1 abierto, 2 cerrando, 3 cerrado
bufferedAmount número Bytes en cola pendientes de enviar
protocol string El subprotocolo negociado
url string La URL final
binaryType string 'blob' (por defecto) o 'arraybuffer'
send(datos) método Envía string, Blob, ArrayBuffer o TypedArray
close(codigo?, motivo?) método Cierra ordenadamente
eventos open, message, error, close

Tres avisos desde el principio:

  • El constructor conecta ya. No hay un .connect(). Si registras los oyentes demasiado tarde, puedes perderte el open. Regístralos en la línea siguiente al constructor, siempre.
  • El evento error no dice qué pasó. Es una restricción de seguridad, igual que el TypeError genérico de CORS en 07-02. El diagnóstico útil viene en el close que le sigue, con su code.
  • Tras un error siempre llega un close. No pongas la lógica de reconexión en el error: se duplicaría. Ponla en el close.

  1. readyState y el ciclo de vida

stateDiagram-v2
    [*] --> CONNECTING: new WebSocket(url)
    CONNECTING --> OPEN: evento open (handshake OK)
    CONNECTING --> CLOSED: evento error + close (fallo)
    OPEN --> CLOSING: close() o cierre remoto
    CLOSING --> CLOSED: evento close
    CLOSED --> [*]
    CLOSED --> CONNECTING: reconectar() crea un socket NUEVO
Valor Constante Significa ¿send() funciona?
0 WebSocket.CONNECTING Handshake en marcha No: lanza InvalidStateError
1 WebSocket.OPEN Listo
2 WebSocket.CLOSING Cerrando No (se ignora)
3 WebSocket.CLOSED Cerrado o fallido No

El error de novato consiste en enviar justo después de crear el socket:

const socket = new WebSocket(URL);
socket.send('hola');        // ✗ InvalidStateError: el estado es CONNECTING, no OPEN

Hay que esperar al open, o comprobar el estado:

if (socket.readyState === WebSocket.OPEN) socket.send(mensaje);
else cola.push(mensaje);    // ← el patrón del apartado 11

Y un detalle que se olvida: un socket cerrado no se reabre. CLOSED es terminal. Reconectar significa construir un WebSocket nuevo, exactamente igual que un AbortController abortado no se reutiliza (07-03).

  1. Enviar y recibir: send, message y JSON

send() acepta texto o binario, pero no objetos. Como en fetch y en localStorage, la serialización es tuya:

// ✗ Se convierte con String() → '[object Object]'
socket.send({ tipo: 'ping' });

// ✓
socket.send(JSON.stringify({ tipo: 'ping' }));

Y en recepción, evento.data es siempre texto (o binario), nunca un objeto ya parseado:

socket.addEventListener('message', (evento) => {
  let mensaje;
  try {
    mensaje = JSON.parse(evento.data);
  } catch {
    console.warn('Mensaje no-JSON, ignorado:', evento.data);
    return;                                    // ← nunca dejes que un mensaje malo tumbe el canal
  }
  manejar(mensaje);
});

Ese try/catch no es paranoia: un canal abierto recibe lo que el servidor mande, y un error de parseo sin capturar rompe el manejador y puede dejar el canal inservible.

Para binario, binaryType decide qué recibes:

socket.binaryType = 'arraybuffer';             // en lugar del Blob por defecto
socket.addEventListener('message', (evento) => {
  if (typeof evento.data === 'string') manejarTexto(evento.data);
  else manejarBinario(new DataView(evento.data));
});

En Nómada Tareas todo es JSON: el volumen es minúsculo y la legibilidad al depurar vale mucho más que unos bytes.

  1. Un protocolo de mensajes para Nómada Tareas

Aquí está la diferencia entre un WebSocket que funciona y uno que se vuelve inmantenible. HTTP te daba estructura gratis (verbo, ruta, estado). Un WebSocket es un tubo de bytes: la estructura la defines tú, y hay que definirla antes de escribir código.

El formato mínimo que funciona bien:

{
  "tipo": "tarea:actualizada",
  "carga": { "id": 6, "estado": "en-curso" },
  "meta": { "id": "a3f1", "emisor": "cliente-marta", "ts": "2026-09-20T10:14:32.120Z", "version": 4 }
}
Campo Para qué
tipo Discrimina el mensaje. Con dominio:acción, igual que los CustomEvent de 06-04
carga Los datos, con la misma forma que devuelve la API REST
meta.id Identificador único del mensaje: sirve para descartar duplicados y correlacionar respuestas
meta.emisor Quién lo originó: permite ignorar el eco de tus propios cambios
meta.ts Marca de tiempo ISO, para ordenar y resolver conflictos
meta.version Versión de la tarea, la pieza clave del apartado 13

El catálogo completo del proyecto:

Tipo Sentido Carga Qué provoca
suscribir cliente → servidor { tablero, desdeVersion } El servidor apunta al cliente y manda el estado
estado:completo servidor → cliente { tareas: [...] } La vista reemplaza el tablero entero
tarea:creada ambos La tarea completa Añadir tarjeta
tarea:actualizada ambos { id, ...cambios, version } Reconciliar esa tarjeta
tarea:borrada ambos { id } Quitar tarjeta
presencia servidor → cliente { conectados: ['Marta','Iván'] } Mostrar quién está mirando
ping / pong ambos Heartbeat (apartado 10)
error servidor → cliente { codigo, mensaje } Avisar; posible rechazo de un cambio

Cuatro reglas de diseño de protocolo que vale la pena seguir siempre:

  • Un tipo por mensaje, siempre presente. El manejador es un switch sobre tipo, o un objeto de funciones indexado por tipo; nunca una cadena de if que adivina por la forma de la carga.
  • Versiona el protocolo, como versionaste el formato de localStorage en 07-01. Un campo v: 1 en el suscribir te permitirá cambiar de formato sin romper a los clientes viejos.
  • La carga debe tener la misma forma que la API REST. Así Tarea.desdeJSON sirve para las dos vías, y no mantienes dos modelos.
  • Reutiliza los nombres de tus CustomEvent. 'tarea:actualizada' en el socket y 'tarea:cambiada' en el DOM: la traducción es de una línea y el vocabulario es uno solo.

  1. Cerrar bien: close(codigo, motivo)

socket.close(1000, 'El usuario cerró el tablero');

Los códigos de cierre son un vocabulario estándar, y saber leerlos es lo que convierte «no funciona» en un diagnóstico:

Código Nombre Significa ¿Reconectar?
1000 Normal Closure Cierre limpio y previsto No
1001 Going Away La página se cierra o navega No
1005 No Status No se recibió código (lo pone el navegador)
1006 Abnormal Closure Conexión perdida sin cierre limpio
1008 Policy Violation El servidor rechaza por política (auth) No, arregla la credencial
1009 Message Too Big Mensaje demasiado grande No, trocea
1011 Internal Error Error interno del servidor
1012 / 1013 Service Restart / Try Again Later Mantenimiento Sí, con espera
4000-4999 Códigos propios de tu aplicación Tú decides

El 1006 es el que más verás: significa «se cortó y nadie dijo adiós» —wifi caído, portátil suspendido, un proxy que mató la conexión inactiva—. Es la señal de reconectar.

El rango 4000-4999 está reservado para ti, y merece la pena usarlo:

// En el servidor
if (!tokenValido) socket.close(4001, 'Token caducado');
if (tableroNoExiste) socket.close(4004, 'Tablero desconocido');
// En el cliente
socket.addEventListener('close', (evento) => {
  if (evento.code === 4001) { irAIniciarSesion(); return; }    // no reconectar
  if (evento.code === 1000) return;                            // cierre pedido por nosotros
  reconectar();
});

Dos detalles finales: evento.wasClean indica si hubo handshake de cierre; y el motivo está limitado a 123 bytes, así que es una etiqueta, no una explicación.

  1. Reconexión con retroceso exponencial

Un WebSocket se cae. El wifi del taller parpadea, el móvil pasa de wifi a datos, el portátil se suspende, un proxy corta las conexiones inactivas a los 60 segundos. Una aplicación seria asume que la conexión se pierde y se reconecta sola.

La técnica es la misma de 07-03: retroceso exponencial con jitter. Reconectar inmediatamente y en bucle es una forma eficaz de tumbar tu propio servidor cuando se reinicia y mil clientes vuelven a la vez.

class Reconector {
  #intentos = 0;
  #maxIntentos;
  #baseMs;
  #maxMs;

  constructor({ maxIntentos = 10, baseMs = 500, maxMs = 30000 } = {}) {
    this.#maxIntentos = maxIntentos;
    this.#baseMs = baseMs;
    this.#maxMs = maxMs;
  }

  /** Devuelve los ms a esperar, o null si hay que rendirse. */
  siguienteEspera() {
    if (this.#intentos >= this.#maxIntentos) return null;
    const exponencial = Math.min(this.#baseMs * 2 ** this.#intentos, this.#maxMs);
    this.#intentos += 1;
    return exponencial + Math.random() * exponencial * 0.3;    // jitter del 30 %
  }

  /** Se llama al conectar con éxito: la siguiente caída vuelve a empezar por abajo. */
  reiniciar() {
    this.#intentos = 0;
  }
}

La secuencia resultante: 500 ms, 1 s, 2 s, 4 s, 8 s, 16 s, 30 s, 30 s… más el jitter. Rápido cuando es un parpadeo, tranquilo cuando el servidor está caído de verdad.

Dos mejoras que marcan la diferencia:

// 1 · Si el navegador dice que no hay red, no gastes intentos: espera al evento 'online'
if (!navigator.onLine) {
  window.addEventListener('online', () => this.conectar(), { once: true });
  return;
}

// 2 · Si la pestaña está oculta, no reconectes con prisa: ahorra batería
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible' && this.#estado === 'cerrado') this.conectar();
});

Y una decisión de producto: cuando te rindas, dilo. Un banner «Sin conexión con el tablero — Reintentar» con un botón es infinitamente mejor que reintentar en silencio para siempre o que fingir que todo va bien.

  1. Heartbeat: detectar conexiones zombis

Existe un fallo especialmente traicionero: la conexión parece abierta —readyState es 1— pero no llega nada. Ocurre cuando un router NAT o un proxy descarta la conexión sin avisar a ninguno de los dos extremos. El navegador no tiene forma de saberlo, y tu tablero se queda congelado sin ningún error.

La solución es un latido: enviar un ping periódico y esperar un pong. Si el pong no llega a tiempo, se da la conexión por muerta y se reconecta.

sequenceDiagram
    participant C as Cliente
    participant S as Servidor

    loop cada 25 s
        C->>S: {"tipo":"ping"}
        S-->>C: {"tipo":"pong"}
        Note over C: ✅ reinicia el vigilante
    end

    C->>S: {"tipo":"ping"}
    Note over C,S: ❌ la red se cortó sin avisar
    Note over C: pasan 10 s sin pong
    C->>C: socket.close(4008, 'Sin pong')
    C->>C: reconectar()
#latido = { intervalo: null, vigilante: null, cadaMs: 25000, esperaMs: 10000 };

#iniciarLatido() {
  this.#detenerLatido();
  this.#latido.intervalo = setInterval(() => {
    if (this.#socket?.readyState !== WebSocket.OPEN) return;

    this.#socket.send(JSON.stringify({ tipo: 'ping' }));

    // Si no llega el pong a tiempo, la conexión es un zombi
    this.#latido.vigilante = setTimeout(() => {
      console.warn('[nomada] Sin pong: conexión zombi, cerrando.');
      this.#socket.close(4008, 'Sin respuesta al ping');       // provoca 'close' → reconexión
    }, this.#latido.esperaMs);
  }, this.#latido.cadaMs);
}

#alRecibirPong() {
  clearTimeout(this.#latido.vigilante);                        // llegó: cancela el vigilante
  this.#latido.vigilante = null;
}

#detenerLatido() {
  clearInterval(this.#latido.intervalo);
  clearTimeout(this.#latido.vigilante);
  this.#latido.intervalo = this.#latido.vigilante = null;
}

Dos notas de contexto. El protocolo WebSocket tiene tramas ping/pong nativas, pero la API del navegador no las expone: no puedes enviarlas ni escucharlas desde JavaScript. Por eso el latido se implementa en tu propio protocolo de mensajes. Y el intervalo importa: un valor entre 20 y 30 segundos mantiene viva la conexión frente a proxies que cortan al minuto, sin gastar batería.

  1. Encolar mensajes mientras no hay conexión

Marta marca una tarea como hecha justo mientras el socket se está reconectando. Sin cola, ese cambio se pierde en silencio, que es el peor resultado posible.

#cola = [];
#MAX_COLA = 50;

enviar(tipo, carga = {}) {
  const mensaje = {
    tipo,
    carga,
    meta: { id: crypto.randomUUID(), emisor: this.#emisor, ts: new Date().toISOString() }
  };

  if (this.#socket?.readyState === WebSocket.OPEN) {
    this.#socket.send(JSON.stringify(mensaje));
    return true;
  }

  // Sin conexión: a la cola, con tope para no crecer sin límite
  if (this.#cola.length >= this.#MAX_COLA) this.#cola.shift();     // descarta el más viejo
  this.#cola.push(mensaje);
  return false;                                                     // ← quien llama sabe que no salió
}

/** Se llama en el evento 'open': vacía lo acumulado. */
#vaciarCola() {
  const pendientes = this.#cola;
  this.#cola = [];
  for (const mensaje of pendientes) {
    this.#socket.send(JSON.stringify(mensaje));
  }
}

Cuatro decisiones que hay que tomar conscientemente:

  • Tope de la cola. Sin límite, una desconexión larga acaba consumiendo memoria. Cincuenta mensajes es un tope razonable; al superarlo, descarta los más antiguos.
  • Deduplicar por recurso. Si Marta cambia tres veces el estado de la tarea 6, solo importa el último. Una cola que colapse los mensajes del mismo tipo + carga.id evita enviar historia irrelevante.
  • Persistir o no. Una cola en memoria muere al recargar. Si los cambios son valiosos, guárdala en localStorage con el repositorio de 07-01.
  • Devolver si salió o no. El return false permite a la vista marcar la tarjeta como «pendiente de sincronizar», que es lo honesto (07-03).

  1. Integrar con los CustomEvent de la vista

Aquí encaja limpiamente con lo que ya construiste. El canal no conoce la vista: traduce mensajes entrantes a CustomEvent sobre document, y quien quiera escuchar, escucha. Es exactamente el desacoplamiento de 06-04.

flowchart LR
    S[Servidor] -->|"WebSocket message"| C["CanalTablero"]
    C -->|"parsea y valida"| T{"switch (tipo)"}
    T -->|"tarea:actualizada"| E1["emitir(document, 'tarea:cambiada')"]
    T -->|"estado:completo"| E2["emitir(document, 'tablero:actualizado')"]
    E1 --> V["TableroVista<br/>escucha y reconcilia"]
    E2 --> V
    V --> D["DOM"]
// js/datos/tiempo-real.js (fragmento del manejo de entrada)
import { EVENTOS, emitir } from '../vista/eventos.js';
import { Tarea } from '../modelo/tarea.js';

#manejar(mensaje) {
  // Ignora el eco de tus propios cambios: ya los aplicaste de forma optimista (07-03)
  if (mensaje.meta?.emisor === this.#emisor) return;

  switch (mensaje.tipo) {
    case 'pong':
      this.#alRecibirPong();
      break;

    case 'estado:completo':
      emitir(document, EVENTOS.TABLERO_ACTUALIZADO, {
        tareas: mensaje.carga.tareas.map((d) => Tarea.desdeJSON(d)),
        origen: 'servidor'
      });
      break;

    case 'tarea:creada':
    case 'tarea:actualizada':
      emitir(document, EVENTOS.TAREA_CAMBIADA, {
        tarea: Tarea.desdeJSON(mensaje.carga),
        version: mensaje.meta?.version,
        origen: 'servidor'
      });
      break;

    case 'tarea:borrada':
      emitir(document, EVENTOS.TAREA_BORRADA, { id: mensaje.carga.id, origen: 'servidor' });
      break;

    case 'presencia':
      emitir(document, EVENTOS.PRESENCIA, { conectados: mensaje.carga.conectados });
      break;

    case 'error':
      console.error('[nomada] El servidor rechazó un cambio:', mensaje.carga);
      emitir(document, EVENTOS.ERROR_REMOTO, mensaje.carga);
      break;

    default:
      console.warn('[nomada] Tipo de mensaje desconocido:', mensaje.tipo);
  }
}

Y en la vista, sin una sola línea que sepa que existen los WebSockets:

// js/app.js
document.addEventListener(EVENTOS.TAREA_CAMBIADA, (evento) => {
  if (evento.detail.origen !== 'servidor') return;          // los propios ya están aplicados
  tablero.reemplazar(evento.detail.tarea);
  vista.render();                                            // reconcilia por data-id (06-06)
  repositorio.guardar(tablero);                              // la copia local, al día (07-01)
});

Ahí se ve el valor de las tres lecciones anteriores juntas: la reconciliación por clave de 06-06 hace que la tarjeta se actualice sin parpadear ni perder el foco, y el repositorio de 07-01 mantiene la copia local sincronizada.

Ese if (mensaje.meta?.emisor === this.#emisor) return merece una explicación. El servidor reenvía cada cambio a todos los clientes, incluido quien lo originó. Si aplicaras tu propio eco, la tarjeta que ya moviste optimistamente parpadearía o revertiría a un estado intermedio. Filtrar por emisor lo evita.

  1. Conflictos: dos personas, una misma tarea

El problema no es técnico, es de producto, y aparece en cuanto hay dos personas. Marta cambia la prioridad de la tarea 6 a «media» en el mismo segundo en que Iván la cambia a «baja». ¿Qué queda?

Estrategia Cómo funciona Ventaja Inconveniente
Última escritura gana (LWW) El último mensaje que llega al servidor manda Trivial de implementar Se pierden cambios en silencio
Versión / updatedAt Cada cambio lleva la versión que el cliente creía tener; el servidor rechaza si no coincide Detecta el conflicto en lugar de ignorarlo Hay que decidir qué hacer al rechazar
Fusión por campos Se combinan cambios de campos distintos Menos conflictos reales Complejo; no vale para el mismo campo
CRDT / transformación operacional Estructuras que convergen solas Edición colaborativa real (tipo documento compartido) Mucha complejidad; para editores de texto

Última escritura gana es aceptable cuando los cambios son atómicos y poco frecuentes, y es lo que hace la mayoría de aplicaciones sencillas. Pero pierde datos sin avisar, y en un tablero de trabajo eso genera desconfianza: «yo puse alta, ¿quién lo cambió?».

La opción sana para Nómada Tareas es control de versión optimista, el equivalente en tiempo real del ETag / If-Match de HTTP:

// El cliente envía qué versión creía tener
canal.enviar('tarea:actualizada', {
  id: 6,
  prioridad: 'media',
  versionBase: tarea.version        // ← 4, la que tenía cuando Marta pulsó
});
// El servidor (pseudocódigo, para que se entienda la regla)
if (tareaEnBd.version !== mensaje.carga.versionBase) {
  responder({ tipo: 'error', carga: {
    codigo: 'conflicto',
    id: mensaje.carga.id,
    actual: tareaEnBd            // le devuelve el estado real
  }});
} else {
  tareaEnBd.version += 1;
  guardar(tareaEnBd);
  difundirATodos({ tipo: 'tarea:actualizada', carga: tareaEnBd, meta: { version: tareaEnBd.version } });
}

Y en el cliente, un conflicto se maneja sin perder el trabajo de nadie:

document.addEventListener(EVENTOS.ERROR_REMOTO, (evento) => {
  const { codigo, id, actual } = evento.detail;
  if (codigo !== 'conflicto') return;

  const mia = tablero.buscarPorId(id);
  vista.mostrarConflicto({
    mensaje: `«${actual.titulo}» fue modificada por otra persona mientras la editabas.`,
    opciones: [
      { texto: 'Ver la versión actual',  alPulsar: () => { tablero.reemplazar(Tarea.desdeJSON(actual)); vista.render(); } },
      { texto: 'Sobrescribir con la mía', alPulsar: () => canal.enviar('tarea:actualizada', { ...mia.toJSON(), versionBase: actual.version }) }
    ]
  });
});

La regla de oro: un conflicto detectado y mostrado siempre es mejor que un cambio perdido en silencio. Y una advertencia importante: la versión la controla el servidor, nunca el cliente. Si el cliente pudiera decidir la versión, el mecanismo no serviría de nada.

  1. Seguridad: wss, autenticación y validación

Los WebSockets tienen un perfil de seguridad propio, y tres reglas no negociables.

Usa siempre wss://. Sobre ws:// todo viaja en claro: mensajes, tokens, datos. Además, una página servida por HTTPS no puede abrir un ws:// —el navegador lo bloquea como contenido mixto—, así que en producción no es opcional.

Autentica la conexión, y ten cuidado con cómo. El constructor WebSocket no acepta cabeceras: no puedes poner Authorization: Bearer. Las opciones reales:

Método Cómo Pega
Cookie de sesión El navegador la envía en el handshake Requiere mismo sitio y SameSite bien configurado
Token en la URL wss://…/tablero?token=abc Queda en logs del servidor y del proxy. Usa tokens de un solo uso y corta vida
Mensaje de autenticación Conectar y enviar {tipo:'auth', token} como primer mensaje El servidor debe cerrar si no llega en N segundos
Subprotocolo new WebSocket(url, ['bearer', token]) Truco extendido, pero abusa del campo

La opción más limpia suele ser la tercera: conectar, autenticar como primer mensaje y que el servidor cierre con 4001 si no llega credencial válida en unos segundos.

Valida SIEMPRE en el servidor. Esta es la más importante y la más ignorada. Un WebSocket abierto es un canal por el que el cliente puede enviar lo que quiera, y cualquiera puede abrir uno con una línea desde la consola. Todo lo que llega por el canal es entrada no confiable, exactamente igual que un formulario:

  • Comprueba la autorización en cada mensaje, no solo al conectar. Que Iván pueda ver el tablero no significa que pueda borrar tareas de Lucía.
  • Valida la forma y el contenido de cada carga. Las reglas R1-R10 del modelo se aplican en el servidor, no solo en el navegador.
  • Comprueba el origen en el handshake: el navegador envía la cabecera Origin, y el servidor debe rechazar los que no reconozca. Los WebSockets no están sujetos a la política de mismo origen de CORS, así que sin esta comprobación cualquier web podría conectarse con las cookies del usuario. Ese ataque tiene nombre: Cross-Site WebSocket Hijacking.
  • Limita el tamaño de los mensajes y la frecuencia (rate limiting): un cliente malicioso puede intentar agotar la memoria del servidor.
  • Nunca pintes con innerHTML un contenido que llegó por el canal. Es entrada de otro usuario, y el XSS de 06-02 acecha igual.

Y la advertencia de compliance que acompaña a todo el módulo: un canal de tiempo real que transporte datos personales reales (nombres, ubicaciones, mensajes entre personas) entra en el ámbito de la normativa de protección de datos, con obligaciones de cifrado, minimización y retención. Marta, Iván y Lucía son ficticios; tus usuarios, no.

  1. Nómada Tareas: js/datos/tiempo-real.js

Todo junto, en una clase con la misma filosofía que el resto de la capa datos/: no conoce el DOM, no conoce la vista, solo traduce entre el servidor y los eventos de la aplicación.

// js/datos/tiempo-real.js
import { EVENTOS, emitir } from '../vista/eventos.js';
import { Tarea } from '../modelo/tarea.js';

const ESTADOS = Object.freeze({
  CERRADO: 'cerrado', CONECTANDO: 'conectando', ABIERTO: 'abierto', RENDIDO: 'rendido'
});

export class CanalTablero extends EventTarget {
  #url;
  #socket = null;
  #estado = ESTADOS.CERRADO;
  #emisor = crypto.randomUUID();          // identifica a ESTE cliente, para ignorar el eco
  #cola = [];
  #cerradoAPropósito = false;
  #intentos = 0;
  #reintentoId = null;
  #latido = { intervalo: null, vigilante: null };

  static MAX_INTENTOS = 10;
  static MAX_COLA = 50;
  static LATIDO_MS = 25000;
  static ESPERA_PONG_MS = 10000;

  constructor(url) {
    super();
    this.#url = url;
  }

  get estado()   { return this.#estado; }
  get conectado(){ return this.#socket?.readyState === WebSocket.OPEN; }
  get pendientes(){ return this.#cola.length; }

  // ─────────────────────────── conexión ───────────────────────────

  conectar() {
    if (this.#estado === ESTADOS.CONECTANDO || this.conectado) return;

    if (!navigator.onLine) {                                   // sin red: espera, no gastes intentos
      window.addEventListener('online', () => this.conectar(), { once: true });
      return;
    }

    this.#cerradoAPropósito = false;
    this.#cambiarEstado(ESTADOS.CONECTANDO);

    this.#socket = new WebSocket(this.#url);
    this.#socket.addEventListener('open', () => this.#alAbrir());
    this.#socket.addEventListener('message', (e) => this.#alMensaje(e));
    this.#socket.addEventListener('error', () => console.warn('[nomada] Error en el canal.'));
    this.#socket.addEventListener('close', (e) => this.#alCerrar(e));
  }

  /** Cierre voluntario: NO reconecta. */
  desconectar(motivo = 'Cierre del tablero') {
    this.#cerradoAPropósito = true;
    clearTimeout(this.#reintentoId);
    this.#detenerLatido();
    this.#socket?.close(1000, motivo);
    this.#socket = null;
    this.#cambiarEstado(ESTADOS.CERRADO);
  }

  #alAbrir() {
    this.#intentos = 0;
    this.#cambiarEstado(ESTADOS.ABIERTO);
    this.#iniciarLatido();

    this.enviar('suscribir', { tablero: 'taller-nomada', v: 1 });
    this.#vaciarCola();
  }

  #alCerrar(evento) {
    this.#detenerLatido();
    this.#socket = null;

    if (this.#cerradoAPropósito || evento.code === 1000) {
      this.#cambiarEstado(ESTADOS.CERRADO);
      return;
    }
    if (evento.code === 4001) {                                // token inválido: no insistas
      this.#cambiarEstado(ESTADOS.RENDIDO);
      emitir(document, EVENTOS.ERROR_REMOTO, { codigo: 'sesion', mensaje: 'Sesión caducada.' });
      return;
    }
    this.#programarReconexion(evento.code);
  }

  #programarReconexion(codigo) {
    if (this.#intentos >= CanalTablero.MAX_INTENTOS) {
      this.#cambiarEstado(ESTADOS.RENDIDO);                    // la vista mostrará «Reintentar»
      return;
    }
    const exponencial = Math.min(500 * 2 ** this.#intentos, 30000);
    const espera = exponencial + Math.random() * exponencial * 0.3;   // jitter
    this.#intentos += 1;

    console.warn(`[nomada] Canal cerrado (${codigo}). Reintento ${this.#intentos} en ${Math.round(espera)} ms.`);
    this.#cambiarEstado(ESTADOS.CERRADO);
    this.#reintentoId = setTimeout(() => this.conectar(), espera);
  }

  // ─────────────────────────── mensajes ───────────────────────────

  enviar(tipo, carga = {}) {
    const mensaje = {
      tipo, carga,
      meta: { id: crypto.randomUUID(), emisor: this.#emisor, ts: new Date().toISOString() }
    };

    if (this.conectado) {
      this.#socket.send(JSON.stringify(mensaje));
      return true;
    }
    if (this.#cola.length >= CanalTablero.MAX_COLA) this.#cola.shift();
    this.#cola.push(mensaje);
    return false;                                              // la vista marca «sin sincronizar»
  }

  #vaciarCola() {
    const pendientes = this.#cola;
    this.#cola = [];
    for (const mensaje of pendientes) this.#socket.send(JSON.stringify(mensaje));
  }

  #alMensaje(evento) {
    let mensaje;
    try {
      mensaje = JSON.parse(evento.data);
    } catch {
      console.warn('[nomada] Mensaje no-JSON descartado.');
      return;                                                  // un mensaje malo no tumba el canal
    }
    if (typeof mensaje?.tipo !== 'string') return;
    if (mensaje.meta?.emisor === this.#emisor) return;          // eco de mis propios cambios

    this.#despachar(mensaje);
  }

  #despachar(mensaje) {
    switch (mensaje.tipo) {
      case 'pong':
        clearTimeout(this.#latido.vigilante);
        break;
      case 'estado:completo':
        emitir(document, EVENTOS.TABLERO_ACTUALIZADO, {
          tareas: mensaje.carga.tareas.map((d) => Tarea.desdeJSON(d)), origen: 'servidor'
        });
        break;
      case 'tarea:creada':
      case 'tarea:actualizada':
        emitir(document, EVENTOS.TAREA_CAMBIADA, {
          tarea: Tarea.desdeJSON(mensaje.carga), version: mensaje.meta?.version, origen: 'servidor'
        });
        break;
      case 'tarea:borrada':
        emitir(document, EVENTOS.TAREA_BORRADA, { id: mensaje.carga.id, origen: 'servidor' });
        break;
      case 'presencia':
        emitir(document, EVENTOS.PRESENCIA, { conectados: mensaje.carga.conectados });
        break;
      case 'error':
        emitir(document, EVENTOS.ERROR_REMOTO, mensaje.carga);
        break;
      default:
        console.warn('[nomada] Tipo desconocido:', mensaje.tipo);
    }
  }

  // ─────────────────────────── latido ───────────────────────────

  #iniciarLatido() {
    this.#detenerLatido();
    this.#latido.intervalo = setInterval(() => {
      if (!this.conectado) return;
      this.#socket.send(JSON.stringify({ tipo: 'ping', meta: { emisor: this.#emisor } }));
      this.#latido.vigilante = setTimeout(
        () => this.#socket?.close(4008, 'Sin respuesta al ping'),
        CanalTablero.ESPERA_PONG_MS
      );
    }, CanalTablero.LATIDO_MS);
  }

  #detenerLatido() {
    clearInterval(this.#latido.intervalo);
    clearTimeout(this.#latido.vigilante);
    this.#latido.intervalo = this.#latido.vigilante = null;
  }

  #cambiarEstado(nuevo) {
    if (this.#estado === nuevo) return;
    this.#estado = nuevo;
    this.dispatchEvent(new CustomEvent('estado', { detail: { estado: nuevo } }));
  }
}

Y el uso en la aplicación:

// js/app.js
import { CanalTablero } from './datos/tiempo-real.js';

const canal = new CanalTablero('wss://api.tallernomada.example/v1/tablero');

canal.addEventListener('estado', (evento) => {
  const { estado } = evento.detail;
  $('#indicador-conexion').textContent = {
    abierto: 'En directo', conectando: 'Conectando…',
    cerrado: 'Sin conexión', rendido: 'Sin conexión con el tablero'
  }[estado];
  $('#indicador-conexion').dataset.estado = estado;         // el CSS pinta el punto de color
  $('#reconectar').hidden = estado !== 'rendido';
});

canal.conectar();

// Los cambios locales viajan por el canal además de por la API
document.addEventListener(EVENTOS.TAREA_CAMBIADA, (evento) => {
  if (evento.detail.origen === 'servidor') return;          // no reenvíes lo que vino de fuera
  canal.enviar('tarea:actualizada', evento.detail.tarea.toJSON());
});

// Cierre limpio al abandonar la página
window.addEventListener('pagehide', () => canal.desconectar('Página cerrada'));

Fíjate en que la clase extiende EventTarget: eso le permite emitir sus propios eventos ('estado') con la misma API que cualquier elemento del DOM, sin depender de document. Es un patrón muy útil para objetos de servicio.

  1. Un servidor de pruebas mínimo

Para practicar necesitas un servidor. Con Node y la librería ws, lo justo para que el tablero funcione:

mkdir nomada-ws && cd nomada-ws
npm init -y
npm install ws
// servidor.js — servidor de pruebas MÍNIMO. No apto para producción:
// sin autenticación, sin validación, sin persistencia, sin comprobación de Origin.
import { WebSocketServer } from 'ws';

const servidor = new WebSocketServer({ port: 8080 });

let tareas = [
  { id: 1, titulo: 'Rediseñar la sala polivalente', responsable: 'Iván', prioridad: 'alta',
    estado: 'en-curso', horasEstimadas: 12, fechaLimite: '2026-09-30', version: 1 },
  { id: 6, titulo: 'Presupuesto de la carpintería', responsable: 'Iván', prioridad: 'alta',
    estado: 'pendiente', horasEstimadas: 5, fechaLimite: '2026-09-05', version: 1 }
];

/** Reenvía un mensaje a todos los clientes conectados. */
function difundir(mensaje) {
  const texto = JSON.stringify(mensaje);
  for (const cliente of servidor.clients) {
    if (cliente.readyState === 1) cliente.send(texto);
  }
}

servidor.on('connection', (socket) => {
  console.log('Cliente conectado. Total:', servidor.clients.size);

  socket.on('message', (bruto) => {
    let mensaje;
    try { mensaje = JSON.parse(bruto); } catch { return; }

    switch (mensaje.tipo) {
      case 'ping':
        socket.send(JSON.stringify({ tipo: 'pong' }));
        break;

      case 'suscribir':
        socket.send(JSON.stringify({ tipo: 'estado:completo', carga: { tareas } }));
        difundir({ tipo: 'presencia', carga: { conectados: servidor.clients.size } });
        break;

      case 'tarea:actualizada': {
        const actual = tareas.find((t) => t.id === mensaje.carga.id);
        if (!actual) return;

        // Control de versión optimista (apartado 13)
        if (mensaje.carga.versionBase !== undefined && mensaje.carga.versionBase !== actual.version) {
          socket.send(JSON.stringify({
            tipo: 'error', carga: { codigo: 'conflicto', id: actual.id, actual }
          }));
          return;
        }
        Object.assign(actual, mensaje.carga, { version: actual.version + 1 });
        difundir({ tipo: 'tarea:actualizada', carga: actual,
                   meta: { emisor: mensaje.meta?.emisor, version: actual.version } });
        break;
      }
    }
  });

  socket.on('close', () => {
    difundir({ tipo: 'presencia', carga: { conectados: servidor.clients.size } });
  });
});

console.log('Servidor de pruebas en ws://localhost:8080');
node servidor.js

Y en el cliente, new CanalTablero('ws://localhost:8080'). Abre dos pestañas con la aplicación, mueve una tarjeta en una y mírala moverse en la otra: ese es el momento en que se entiende para qué sirve todo esto.

Este servidor es un juguete didáctico. Le falta absolutamente todo lo del apartado 14: autenticación, autorización por mensaje, validación de las reglas R1-R10, comprobación de Origin, límites de tamaño y de frecuencia, y persistencia. No lo despliegues.

Para probar el cliente sin escribir servidor existen servicios públicos de eco como wss://echo.websocket.org o wss://ws.postman-echo.com/raw, que devuelven lo que les envías. Sirven para verificar la conexión, la reconexión y el formato de los mensajes, no la lógica del tablero.

Errores Comunes y Consejos

  • Enviar antes del open. readyState es CONNECTING y send() lanza InvalidStateError. Espera al evento o encola.
  • Intentar reabrir el mismo socket. CLOSED es terminal. Reconectar es construir un WebSocket nuevo.
  • Poner la reconexión en el manejador de error. Tras un error siempre llega un close; harías dos reconexiones. Va en close.
  • Reconectar sin retroceso exponencial. Cuando el servidor se reinicie, mil clientes en bucle lo tumbarán otra vez.
  • Reconectar tras un 1000 o un 4001. El primero fue un cierre pedido; el segundo, un rechazo de credenciales. Insistir no arregla ninguno.
  • No implementar heartbeat. La conexión zombi es el fallo más difícil de diagnosticar: todo "funciona" y no llega nada.
  • Enviar objetos a send(). Se convierten en '[object Object]'. JSON.stringify siempre.
  • Parsear sin try/catch. Un mensaje corrupto rompe el manejador y puede dejar el canal inservible.
  • Aplicar el eco de tus propios mensajes. Provoca parpadeos y estados intermedios visibles. Filtra por meta.emisor.
  • Usar ws:// en producción. Todo viaja en claro y el navegador lo bloquea desde una página HTTPS.
  • Confiar en lo que llega por el canal. Es entrada de usuario. Valida en el servidor, y en el cliente nunca lo pintes con innerHTML.
  • Olvidar cerrar al abandonar la página. Deja conexiones colgando en el servidor. Usa pagehide.
  • Consejo: mira los frames en DevTools. Network → filtro WS → pestaña Messages: verás cada mensaje enviado y recibido con su marca de tiempo. Es la herramienta de diagnóstico principal.
  • Consejo: muestra siempre el estado de la conexión. Un punto verde/ámbar/rojo con texto cuesta diez líneas y evita que el usuario crea que la aplicación está rota.
  • Consejo: prueba desconectando el wifi. Es la única forma de saber si tu reconexión, tu cola y tu heartbeat funcionan de verdad.
  • Consejo: si solo recibes, plantéate SSE. Se reconecta solo, atraviesa proxies y es mucho menos código.

Ejercicios

Ejercicio 1 — Cola con deduplicación y persistencia. Amplía la cola de CanalTablero para que: (a) si se encolan dos mensajes con el mismo tipo y el mismo carga.id, solo se conserve el último; (b) la cola se guarde en localStorage bajo 'nomada:cola:v1' cada vez que cambie, y se recupere al construir el canal; (c) los mensajes con más de una hora de antigüedad (según meta.ts) se descarten al vaciar, porque reenviar un cambio muy viejo puede pisar trabajo más reciente de otra persona.

Ejercicio 2 — Indicador de conexión accesible. Escribe conectarIndicador(canal, elemento) que refleje el estado del canal (conectando, abierto, cerrado, rendido) en un elemento del DOM, con texto legible, un data-estado para el CSS y aria-live="polite" para que se anuncie. Debe mostrar además cuántos mensajes hay pendientes en cola cuando no haya conexión, y ofrecer un botón «Reintentar» solo en el estado rendido que reinicie los intentos y vuelva a conectar. Usa { signal } para poder desconectarlo todo de golpe.

Ejercicio 3 — Resolución de conflictos con versión. Implementa aplicarCambioRemoto(tablero, tarea, version) que aplique un cambio recibido solo si su version es mayor que la que ya se tiene, y devuelva 'aplicado' | 'ignorado' | 'conflicto'. Se considera conflicto cuando la versión remota es menor que la local (nuestro cambio es más nuevo y el servidor aún no lo sabe). Añade enviarConVersion(canal, tarea) que incluya versionBase y, ante un mensaje de error con codigo: 'conflicto', muestre las dos opciones al usuario en lugar de decidir sola.

Soluciones

Solución 1

const CLAVE_COLA = 'nomada:cola:v1';
const MAX_EDAD_MS = 3600000;      // 1 hora

#encolar(mensaje) {
  // (a) Deduplicar: fuera el anterior del mismo tipo y mismo recurso
  const clave = `${mensaje.tipo}:${mensaje.carga?.id ?? ''}`;
  this.#cola = this.#cola.filter((m) => `${m.tipo}:${m.carga?.id ?? ''}` !== clave);

  this.#cola.push(mensaje);
  if (this.#cola.length > CanalTablero.MAX_COLA) this.#cola.shift();
  this.#persistirCola();                                   // (b)
}

#persistirCola() {
  try {
    localStorage.setItem(CLAVE_COLA, JSON.stringify(this.#cola));
  } catch {
    /* sin cuota: la cola sigue viva en memoria, que es lo importante */
  }
}

#recuperarCola() {
  try {
    const guardada = JSON.parse(localStorage.getItem(CLAVE_COLA) ?? '[]');
    this.#cola = Array.isArray(guardada) ? guardada : [];
  } catch {
    this.#cola = [];
  }
}

#vaciarCola() {
  const ahora = Date.now();
  // (c) Descartar lo demasiado viejo: reenviarlo pisaría trabajo más reciente
  const vigentes = this.#cola.filter((m) => ahora - Date.parse(m.meta.ts) < MAX_EDAD_MS);
  const descartados = this.#cola.length - vigentes.length;
  if (descartados > 0) console.warn(`[nomada] ${descartados} mensajes caducados descartados.`);

  this.#cola = [];
  this.#persistirCola();
  for (const mensaje of vigentes) this.#socket.send(JSON.stringify(mensaje));
}

La deduplicación por tipo:id es lo que evita reenviar cinco cambios de estado de la misma tarjeta cuando solo importa el último. Y el descarte por edad es una decisión de producto disfrazada de código: un cambio de hace tres horas casi nunca debe sobrescribir el estado actual, y si lo hiciera, sería el peor tipo de pérdida de datos.

Solución 2

const TEXTOS = Object.freeze({
  conectando: 'Conectando…',
  abierto:    'En directo',
  cerrado:    'Sin conexión',
  rendido:    'Sin conexión con el tablero'
});

export function conectarIndicador(canal, elemento, { signal } = {}) {
  const texto  = elemento.querySelector('.indicador__texto');
  const boton  = elemento.querySelector('.indicador__reintentar');

  elemento.setAttribute('aria-live', 'polite');
  elemento.setAttribute('role', 'status');

  function pintar(estado) {
    const pendientes = canal.pendientes;
    const sufijo = (estado !== 'abierto' && pendientes > 0)
      ? ` · ${pendientes} ${pendientes === 1 ? 'cambio sin enviar' : 'cambios sin enviar'}`
      : '';

    texto.textContent = TEXTOS[estado] + sufijo;      // textContent, nunca innerHTML (06-02)
    elemento.dataset.estado = estado;                 // el CSS pinta el punto de color
    boton.hidden = estado !== 'rendido';
  }

  canal.addEventListener('estado', (evento) => pintar(evento.detail.estado), { signal });

  boton.addEventListener('click', () => {
    canal.reiniciarIntentos();                        // método nuevo: pone #intentos a 0
    canal.conectar();
  }, { signal });

  pintar(canal.estado);                                // estado inicial, sin esperar al primer cambio
  return () => pintar(canal.estado);                   // permite refrescar el contador de pendientes
}

El signal permite desmontar el indicador entero con un solo abort(), tal como aprendiste en 07-03. Y role="status" con aria-live="polite" hace que el cambio de estado se anuncie sin interrumpir: perder la conexión es información relevante también para quien no ve el punto de color.

Solución 3

export function aplicarCambioRemoto(tablero, tareaRemota, versionRemota) {
  const local = tablero.buscarPorId(tareaRemota.id);

  if (local === undefined) {                       // no la teníamos: es nueva para nosotros
    tablero.agregar(tareaRemota);
    return 'aplicado';
  }

  const versionLocal = local.version ?? 0;

  if (versionRemota > versionLocal) {
    tablero.reemplazar(tareaRemota);
    return 'aplicado';
  }
  if (versionRemota === versionLocal) {
    return 'ignorado';                             // ya la tenemos, es el eco de un cambio conocido
  }
  return 'conflicto';                              // la nuestra es más nueva que la del servidor
}
export function enviarConVersion(canal, tarea) {
  return canal.enviar('tarea:actualizada', { ...tarea.toJSON(), versionBase: tarea.version });
}

document.addEventListener(EVENTOS.ERROR_REMOTO, (evento) => {
  const { codigo, id, actual } = evento.detail;
  if (codigo !== 'conflicto') return;

  const mia = tablero.buscarPorId(id);
  vista.mostrarConflicto({
    mensaje: `«${actual.titulo}» cambió mientras la editabas.`,
    opciones: [
      { texto: 'Quedarme con la del servidor',
        alPulsar: () => { tablero.reemplazar(Tarea.desdeJSON(actual)); vista.render(); } },
      { texto: 'Sobrescribir con la mía',
        alPulsar: () => canal.enviar('tarea:actualizada', { ...mia.toJSON(), versionBase: actual.version }) }
    ]
  });
});

Lo esencial no es el algoritmo, sino la decisión de diseño: el código no elige por el usuario. Cuando hay un conflicto real, se muestra, con las dos versiones y dos botones. El caso 'ignorado' es igual de importante: sin él, el eco de un cambio que ya aplicaste provocaría un repintado innecesario y, con optimistic UI de por medio, un parpadeo visible.

Conclusión

El tablero de Taller Nómada ya está vivo. Sabes por qué el modelo petición-respuesta no basta —el servidor no puede hablar primero— y conoces las cuatro técnicas que lo resuelven, con criterio para elegir: polling cuando la latencia da igual, long polling cuando la compatibilidad manda, SSE cuando solo recibes (se reconecta solo y es mucho menos código) y WebSockets cuando necesitas los dos sentidos con baja latencia. Entiendes el protocolo: el handshake HTTP con Upgrade y el 101 Switching Protocols, los esquemas ws:// y wss://, el canal full-duplex donde ya no hay verbos ni códigos de estado, y el coste real que implica —el servidor mantiene estado por conexión, y eso complica el escalado—.

Dominas la API: el constructor que conecta de inmediato, los cuatro eventos, el readyState con sus cuatro valores y la regla de que CLOSED es terminal —reconectar es construir un socket nuevo—, send() que solo acepta texto o binario, evento.data que nunca llega parseado, y close(codigo, motivo) con su vocabulario de códigos, del 1000 limpio al 1006 que grita «reconecta» y el rango 4000-4999 que es tuyo. Has diseñado un protocolo de mensajes con tipo, carga y meta, porque un WebSocket es un tubo de bytes y la estructura la pones tú; y lo has alineado con los nombres de los CustomEvent de 06-04 para tener un solo vocabulario.

Sabes que una conexión real se cae, y tienes las tres defensas: reconexión con retroceso exponencial y jitter, que no insiste ante un 1000 ni ante un 4001; heartbeat ping/pong con vigilante para cazar la conexión zombi que el navegador no detecta; y cola de mensajes con tope, deduplicación y descarte por edad para que un cambio hecho sin conexión no se pierda en silencio. Sabes integrar el canal con la vista sin acoplarlos —el canal emite CustomEvent, la vista escucha y reconcilia por data-id como en 06-06, el repositorio de 07-01 mantiene la copia local al día— y filtrar el eco de tus propios cambios por meta.emisor para que el optimistic UI de 07-03 no parpadee. Y sabes que los conflictos son un problema de producto: última escritura gana pierde datos en silencio, y el control de versión optimista los detecta y deja decidir a la persona, que siempre es mejor.

En seguridad quedan tres reglas grabadas: wss obligatorio, autenticación real —recordando que el constructor no admite cabeceras y que un token en la URL acaba en los registros—, y sobre todo validar siempre en el servidor, comprobando el Origin en el handshake, porque los WebSockets no están sujetos a la política de mismo origen y sin esa comprobación cualquier web podría conectarse con las credenciales de tu usuario.

Con js/datos/tiempo-real.js y su CanalTablero, Nómada Tareas es una aplicación conectada, robusta y colaborativa. Le queda una debilidad grande, y es la más cotidiana de todas: depende por completo de tener red. Si Iván baja al almacén de serigrafía, donde no llega el wifi, la aplicación no arranca: el HTML, el CSS y los módulos ES viven en el servidor, y sin conexión el navegador no tiene nada que cargar. La copia de localStorage está ahí, pero nadie puede leerla porque la página ni se abre. Para cruzar esa frontera hace falta algo distinto: un proceso que viva fuera de la página, que intercepte las peticiones de red antes de que salgan y sepa responderlas desde una caché. Eso es Service Workers y Aplicaciones Web Progresivas (PWAs), donde Nómada Tareas aprenderá a funcionar sin conexión y a instalarse en el dispositivo como una aplicación más.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados