The previous lesson left Nómada Tasks connected and robust, but with a limit that no improvement to fetch can solve: the browser only finds things out when it asks. If Iván drags a task to "under way" from the workshop, Marta's board will keep showing the old state until she refreshes. Asking every five seconds means wasting battery and bandwidth to hear "nothing new" 95 % of the time. What is needed is to reverse the initiative: to let the server speak first. That is a WebSocket, a permanent, bidirectional connection through which messages travel both ways the moment they happen. In this lesson you will compare the four real-time techniques, understand the handshake that turns an HTTP request into a persistent channel, master the browser's WebSocket API, design a small message protocol, solve reconnection and simultaneous-editing conflicts, and write js/data/realtime.js with the BoardChannel class.

Contents

  1. When request-response is not enough
  2. The four real-time techniques
  3. The protocol: from handshake to persistent channel
  4. The browser's WebSocket API
  5. readyState and the life cycle
  6. Sending and receiving: send, message and JSON
  7. A message protocol for Nómada Tasks
  8. Closing properly: close(code, reason)
  9. Reconnection with exponential backoff
  10. Heartbeat: detecting zombie connections
  11. Queuing messages while there is no connection
  12. Integrating with the view's CustomEvents
  13. Conflicts: two people, one task
  14. Security: wss, authentication and validation
  15. Nómada Tasks: js/data/realtime.js
  16. A minimal test server
  17. Common Mistakes and Tips
  18. Exercises
  19. Conclusion

  1. When request-response is not enough

HTTP is a request and response protocol: the client asks, the server answers, the connection ends. That model is perfect for loading a page or saving a form, and it is structurally incapable of one thing: letting the server speak up on its own initiative.

The cases where that hurts are recognizable:

  • A shared board where three people move cards at once.
  • A chat, where waiting five seconds for a message to appear is unacceptable.
  • A live metrics dashboard, notifications, stock prices, a multiplayer game.
  • A collaborative editor, where you see the other person's cursor.

In Nómada Tasks the scenario is concrete: Marta has the board open on the big screen in the room. Iván, from the workshop, marks the task "Carpentry workshop quote" as under way. The right behavior is for the card to move on Marta's screen, on its own, in under a second, without anyone pressing anything.

  1. The four real-time techniques

Before choosing WebSockets it is worth knowing what the alternatives are, because a simpler one is often enough.

Technique How it works Direction Cost When to choose it
Polling The client asks every N seconds Client → server High: lots of empty requests Data that changes rarely and latency does not matter (metrics every minute)
Long polling The client asks and the server holds the response until there is something Client → server Medium Maximum compatibility, no special infrastructure
SSE (EventSource) An HTTP connection the server keeps open and through which it pushes events Server → client (one-way) Low Notifications, alerts, a panel that only receives
WebSockets Persistent full-duplex connection over TCP Both ways Low per message, requires a stateful server Chat, collaboration, games, shared boards

Two comparisons worth being clear about:

Polling versus WebSocket, in numbers. Polling every 5 seconds is 720 requests per hour per user. Each one drags along complete HTTP headers (cookies, User-Agent, Accept…): easily 1 KB out and another back even when there is no news. That is over 1 MB an hour per person to say nothing. A WebSocket pays the cost of the handshake once and after that each message is a few bytes of frame header.

SSE versus WebSocket. SSE is underrated. It is plain HTTP —it goes through proxies without trouble—, it reconnects on its own and it is trivial to implement on the server. Its limitation is that the client cannot send on the same channel: for that you use fetch separately.

// Server-Sent Events: surprisingly simple for receiving
const source = new EventSource('https://api.tallernomada.example/v1/events');

source.addEventListener('task:updated', (event) => {
  const task = JSON.parse(event.data);
  view.updateCard(task);
});

source.onerror = () => console.warn('SSE reconnecting on its own…');   // ← the browser does it

The decision rule: if you only receive, SSE. If you also send frequently and need low latency both ways, WebSockets. Nómada Tasks is in the second case: Marta and Iván do not just look, they also move cards.

  1. The protocol: from handshake to persistent channel

A WebSocket starts life as an HTTP request. That is the key to its design: it reuses port 443 and passes through existing infrastructure.

sequenceDiagram
    participant C as Browser
    participant S as Server

    Note over C,S: Phase 1 · Handshake (HTTP)
    C->>S: GET /board 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: Phase 2 · Persistent channel (no longer HTTP)
    C->>S: {"type":"subscribe","payload":{"board":"taller-nomada"}}
    S-->>C: {"type":"state:full","payload":{…6 tasks…}}
    S-->>C: {"type":"task:updated","payload":{"id":6,"status":"in-progress"}}
    C->>S: {"type":"task:updated","payload":{"id":2,"status":"done"}}
    S-->>C: {"type":"pong"}

    Note over C,S: Phase 3 · Closing
    C->>S: close(1000, "View closed")
    S-->>C: close ack

The points that matter:

  • 101 Switching Protocols is the code that authorizes the switch. It is the only 1xx you will see in your life as a frontend developer, and it already appeared in the table in 07-02.
  • After the 101 there is no more HTTP. No verbs, no paths, no status codes, no per-message headers. Just data frames in both directions.
  • The schemes are ws:// and wss://, analogous to http:// and https://. wss is WebSocket over TLS.
  • It is full-duplex: both ends can send at any moment, without taking turns and without one message being the "response" to another. That absence of request-response correlation is the biggest mental shift compared with fetch.
  • The connection is stateful. The server keeps an object per connected client, which complicates horizontal scaling: two servers behind a load balancer do not share connections without help (Redis, a message bus…). It is a real cost you need to know about before choosing.

  1. The browser's WebSocket API

The API is small: one constructor, four events, two methods and a handful of properties.

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

socket.addEventListener('open', () => {
  console.log('Connected');
  socket.send(JSON.stringify({ type: 'subscribe', payload: { board: 'taller-nomada' } }));
});

socket.addEventListener('message', (event) => {
  const message = JSON.parse(event.data);        // event.data is ALWAYS text or binary
  console.log('Received:', message.type);
});

socket.addEventListener('error', () => {
  console.error('Error on the socket');          // ← it does not say which, for security reasons
});

socket.addEventListener('close', (event) => {
  console.log('Closed:', event.code, event.reason, 'clean:', event.wasClean);
});
Member Type What it is
new WebSocket(url, protocols?) constructor Opens the connection immediately
readyState number 0 connecting, 1 open, 2 closing, 3 closed
bufferedAmount number Bytes queued and still to be sent
protocol string The negotiated subprotocol
url string The final URL
binaryType string 'blob' (default) or 'arraybuffer'
send(data) method Sends a string, Blob, ArrayBuffer or TypedArray
close(code?, reason?) method Closes tidily
events open, message, error, close

Three warnings right from the start:

  • The constructor connects right away. There is no .connect(). If you register the listeners too late, you can miss the open. Register them on the line right after the constructor, always.
  • The error event does not say what happened. It is a security restriction, just like the generic CORS TypeError in 07-02. The useful diagnosis comes in the close that follows it, with its code.
  • After an error a close always arrives. Do not put the reconnection logic in the error: it would run twice. Put it in the close.

  1. readyState and the life cycle

stateDiagram-v2
    [*] --> CONNECTING: new WebSocket(url)
    CONNECTING --> OPEN: open event (handshake OK)
    CONNECTING --> CLOSED: error + close events (failure)
    OPEN --> CLOSING: close() or remote close
    CLOSING --> CLOSED: close event
    CLOSED --> [*]
    CLOSED --> CONNECTING: reconnect() creates a NEW socket
Value Constant Means Does send() work?
0 WebSocket.CONNECTING Handshake under way No: throws InvalidStateError
1 WebSocket.OPEN Ready Yes
2 WebSocket.CLOSING Closing No (it is ignored)
3 WebSocket.CLOSED Closed or failed No

The beginner's mistake is sending right after creating the socket:

const socket = new WebSocket(URL);
socket.send('hello');       // ✗ InvalidStateError: the state is CONNECTING, not OPEN

You have to wait for the open, or check the state:

if (socket.readyState === WebSocket.OPEN) socket.send(message);
else queue.push(message);   // ← the pattern from section 11

And one detail that gets forgotten: a closed socket does not reopen. CLOSED is terminal. Reconnecting means constructing a new WebSocket, exactly as an aborted AbortController is not reused (07-03).

  1. Sending and receiving: send, message and JSON

send() accepts text or binary, but not objects. As in fetch and in localStorage, the serialization is yours to do:

// ✗ Converted with String() → '[object Object]'
socket.send({ type: 'ping' });

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

And on receipt, event.data is always text (or binary), never an already-parsed object:

socket.addEventListener('message', (event) => {
  let message;
  try {
    message = JSON.parse(event.data);
  } catch {
    console.warn('Non-JSON message, ignored:', event.data);
    return;                                    // ← never let a bad message bring the channel down
  }
  handle(message);
});

That try/catch is not paranoia: an open channel receives whatever the server sends, and an uncaught parse error breaks the handler and can leave the channel useless.

For binary data, binaryType decides what you get:

socket.binaryType = 'arraybuffer';             // instead of the default Blob
socket.addEventListener('message', (event) => {
  if (typeof event.data === 'string') handleText(event.data);
  else handleBinary(new DataView(event.data));
});

In Nómada Tasks everything is JSON: the volume is tiny and readability while debugging is worth far more than a few bytes.

  1. A message protocol for Nómada Tasks

Here lies the difference between a WebSocket that works and one that becomes unmaintainable. HTTP gave you structure for free (verb, path, status). A WebSocket is a pipe of bytes: you define the structure, and it has to be defined before writing any code.

The minimal format that works well:

{
  "type": "task:updated",
  "payload": { "id": 6, "status": "in-progress" },
  "meta": { "id": "a3f1", "sender": "client-marta", "ts": "2026-09-20T10:14:32.120Z", "version": 4 }
}
Field What for
type Discriminates the message. With domain:action, just like the CustomEvents from 06-04
payload The data, with the same shape the REST API returns
meta.id Unique identifier of the message: used to discard duplicates and correlate responses
meta.sender Who originated it: lets you ignore the echo of your own changes
meta.ts ISO timestamp, for ordering and resolving conflicts
meta.version The task's version, the key piece in section 13

The project's complete catalog:

Type Direction Payload What it triggers
subscribe client → server { board, fromVersion } The server registers the client and sends the state
state:full server → client { tasks: [...] } The view replaces the whole board
task:created both The complete task Add a card
task:updated both { id, ...changes, version } Reconcile that card
task:deleted both { id } Remove a card
presence server → client { connected: ['Marta','Iván'] } Show who is watching
ping / pong both Heartbeat (section 10)
error server → client { code, message } Warn; possibly reject a change

Four protocol design rules that are always worth following:

  • One type per message, always present. The handler is a switch on type, or an object of functions indexed by type; never a chain of ifs that guesses from the shape of the payload.
  • Version the protocol, just as you versioned the localStorage format in 07-01. A v: 1 field in the subscribe will let you change format without breaking old clients.
  • The payload must have the same shape as the REST API. That way Task.fromJSON works for both routes, and you do not maintain two models.
  • Reuse the names of your CustomEvents. 'task:updated' on the socket and 'task:changed' in the DOM: the translation is one line and the vocabulary is a single one.

  1. Closing properly: close(code, reason)

socket.close(1000, 'The user closed the board');

Close codes are a standard vocabulary, and knowing how to read them is what turns "it does not work" into a diagnosis:

Code Name Means Reconnect?
1000 Normal Closure Clean, expected close No
1001 Going Away The page is closing or navigating No
1005 No Status No code was received (set by the browser) Yes
1006 Abnormal Closure Connection lost without a clean close Yes
1008 Policy Violation The server rejects on policy grounds (auth) No, fix the credential
1009 Message Too Big Message too large No, split it up
1011 Internal Error Internal server error Yes
1012 / 1013 Service Restart / Try Again Later Maintenance Yes, with a wait
4000-4999 Your application's own codes You decide

1006 is the one you will see most: it means "it got cut off and nobody said goodbye" —wifi down, laptop suspended, a proxy that killed the idle connection. It is the signal to reconnect.

The 4000-4999 range is reserved for you, and it is worth using:

// On the server
if (!validToken) socket.close(4001, 'Token expired');
if (boardNotFound) socket.close(4004, 'Unknown board');
// On the client
socket.addEventListener('close', (event) => {
  if (event.code === 4001) { goToSignIn(); return; }           // do not reconnect
  if (event.code === 1000) return;                             // close requested by us
  reconnect();
});

Two final details: event.wasClean tells you whether there was a closing handshake; and the reason is limited to 123 bytes, so it is a label, not an explanation.

  1. Reconnection with exponential backoff

A WebSocket goes down. The workshop wifi flickers, the phone switches from wifi to mobile data, the laptop suspends, a proxy cuts idle connections after 60 seconds. A serious application assumes the connection will be lost and reconnects on its own.

The technique is the same as in 07-03: exponential backoff with jitter. Reconnecting immediately in a loop is an effective way of taking down your own server when it restarts and a thousand clients come back at once.

class Reconnector {
  #attempts = 0;
  #maxAttempts;
  #baseMs;
  #maxMs;

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

  /** Returns the ms to wait, or null if it is time to give up. */
  nextDelay() {
    if (this.#attempts >= this.#maxAttempts) return null;
    const exponential = Math.min(this.#baseMs * 2 ** this.#attempts, this.#maxMs);
    this.#attempts += 1;
    return exponential + Math.random() * exponential * 0.3;    // 30 % jitter
  }

  /** Called on a successful connection: the next drop starts again from the bottom. */
  reset() {
    this.#attempts = 0;
  }
}

The resulting sequence: 500 ms, 1 s, 2 s, 4 s, 8 s, 16 s, 30 s, 30 s… plus the jitter. Fast when it is a flicker, calm when the server is really down.

Two improvements that make the difference:

// 1 · If the browser says there is no network, do not spend attempts: wait for the 'online' event
if (!navigator.onLine) {
  window.addEventListener('online', () => this.connect(), { once: true });
  return;
}

// 2 · If the tab is hidden, do not rush to reconnect: it saves battery
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible' && this.#state === 'closed') this.connect();
});

And a product decision: when you give up, say so. A banner reading "No connection to the board — Retry" with a button is infinitely better than retrying silently forever or pretending everything is fine.

  1. Heartbeat: detecting zombie connections

There is a particularly treacherous failure: the connection looks open —readyState is 1— but nothing arrives. It happens when a NAT router or a proxy drops the connection without telling either end. The browser has no way of knowing, and your board sits frozen with no error at all.

The solution is a heartbeat: sending a periodic ping and waiting for a pong. If the pong does not arrive in time, the connection is declared dead and reconnected.

sequenceDiagram
    participant C as Client
    participant S as Server

    loop every 25 s
        C->>S: {"type":"ping"}
        S-->>C: {"type":"pong"}
        Note over C: ✅ resets the watchdog
    end

    C->>S: {"type":"ping"}
    Note over C,S: ❌ the network dropped without warning
    Note over C: 10 s go by with no pong
    C->>C: socket.close(4008, 'No pong')
    C->>C: reconnect()
#heartbeat = { interval: null, watchdog: null, everyMs: 25000, waitMs: 10000 };

#startHeartbeat() {
  this.#stopHeartbeat();
  this.#heartbeat.interval = setInterval(() => {
    if (this.#socket?.readyState !== WebSocket.OPEN) return;

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

    // If the pong does not arrive in time, the connection is a zombie
    this.#heartbeat.watchdog = setTimeout(() => {
      console.warn('[nomada] No pong: zombie connection, closing.');
      this.#socket.close(4008, 'No response to the ping');     // triggers 'close' → reconnection
    }, this.#heartbeat.waitMs);
  }, this.#heartbeat.everyMs);
}

#onPong() {
  clearTimeout(this.#heartbeat.watchdog);                      // it arrived: cancel the watchdog
  this.#heartbeat.watchdog = null;
}

#stopHeartbeat() {
  clearInterval(this.#heartbeat.interval);
  clearTimeout(this.#heartbeat.watchdog);
  this.#heartbeat.interval = this.#heartbeat.watchdog = null;
}

Two notes for context. The WebSocket protocol has native ping/pong frames, but the browser API does not expose them: you cannot send them or listen for them from JavaScript. That is why the heartbeat is implemented in your own message protocol. And the interval matters: a value between 20 and 30 seconds keeps the connection alive against proxies that cut off after a minute, without draining the battery.

  1. Queuing messages while there is no connection

Marta marks a task as done just as the socket is reconnecting. Without a queue, that change is silently lost, which is the worst possible outcome.

#queue = [];
#MAX_QUEUE = 50;

send(type, payload = {}) {
  const message = {
    type,
    payload,
    meta: { id: crypto.randomUUID(), sender: this.#sender, ts: new Date().toISOString() }
  };

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

  // No connection: into the queue, with a cap so it does not grow without limit
  if (this.#queue.length >= this.#MAX_QUEUE) this.#queue.shift();   // drops the oldest
  this.#queue.push(message);
  return false;                                                     // ← the caller knows it did not go out
}

/** Called on the 'open' event: flushes what has piled up. */
#flushQueue() {
  const pending = this.#queue;
  this.#queue = [];
  for (const message of pending) {
    this.#socket.send(JSON.stringify(message));
  }
}

Four decisions that have to be made consciously:

  • A cap on the queue. Without a limit, a long disconnection ends up eating memory. Fifty messages is a reasonable cap; beyond that, drop the oldest.
  • Deduplicate per resource. If Marta changes the status of task 6 three times, only the last one matters. A queue that collapses messages with the same type + payload.id avoids sending irrelevant history.
  • Persist it or not. An in-memory queue dies on reload. If the changes are valuable, store it in localStorage with the repository from 07-01.
  • Return whether it went out or not. The return false lets the view mark the card as "pending sync", which is the honest thing to do (07-03).

  1. Integrating with the view's CustomEvents

Here it slots cleanly into what you have already built. The channel does not know the view: it translates incoming messages into CustomEvents on document, and whoever wants to listen, listens. It is exactly the decoupling from 06-04.

flowchart LR
    S[Server] -->|"WebSocket message"| C["BoardChannel"]
    C -->|"parses and validates"| T{"switch (type)"}
    T -->|"task:updated"| E1["emit(document, 'task:changed')"]
    T -->|"state:full"| E2["emit(document, 'board:updated')"]
    E1 --> V["BoardView<br/>listens and reconciles"]
    E2 --> V
    V --> D["DOM"]
// js/data/realtime.js (excerpt of the incoming handling)
import { EVENTS, emit } from '../view/events.js';
import { Task } from '../model/task.js';

#handle(message) {
  // Ignore the echo of your own changes: you already applied them optimistically (07-03)
  if (message.meta?.sender === this.#sender) return;

  switch (message.type) {
    case 'pong':
      this.#onPong();
      break;

    case 'state:full':
      emit(document, EVENTS.BOARD_UPDATED, {
        tasks: message.payload.tasks.map((d) => Task.fromJSON(d)),
        source: 'server'
      });
      break;

    case 'task:created':
    case 'task:updated':
      emit(document, EVENTS.TASK_CHANGED, {
        task: Task.fromJSON(message.payload),
        version: message.meta?.version,
        source: 'server'
      });
      break;

    case 'task:deleted':
      emit(document, EVENTS.TASK_DELETED, { id: message.payload.id, source: 'server' });
      break;

    case 'presence':
      emit(document, EVENTS.PRESENCE, { connected: message.payload.connected });
      break;

    case 'error':
      console.error('[nomada] The server rejected a change:', message.payload);
      emit(document, EVENTS.REMOTE_ERROR, message.payload);
      break;

    default:
      console.warn('[nomada] Unknown message type:', message.type);
  }
}

And in the view, without a single line that knows WebSockets exist:

// js/app.js
document.addEventListener(EVENTS.TASK_CHANGED, (event) => {
  if (event.detail.source !== 'server') return;             // our own are already applied
  board.replace(event.detail.task);
  view.render();                                             // reconciles by data-id (06-06)
  repository.save(board);                                    // the local copy, up to date (07-01)
});

There you can see the value of the three previous lessons together: the key-based reconciliation from 06-06 makes the card update without flickering or losing focus, and the repository from 07-01 keeps the local copy in sync.

That if (message.meta?.sender === this.#sender) return deserves an explanation. The server forwards every change to all clients, including the one that originated it. If you applied your own echo, the card you already moved optimistically would flicker or revert to an intermediate state. Filtering by sender avoids it.

  1. Conflicts: two people, one task

The problem is not technical, it is a product problem, and it appears as soon as there are two people. Marta changes the priority of task 6 to "medium" in the same second in which Iván changes it to "low". What is left?

Strategy How it works Advantage Drawback
Last write wins (LWW) The last message to reach the server rules Trivial to implement Changes are silently lost
Version / updatedAt Each change carries the version the client believed it had; the server rejects it if it does not match Detects the conflict instead of ignoring it You have to decide what to do on rejection
Field-level merge Changes to different fields are combined Fewer real conflicts Complex; no use for the same field
CRDT / operational transformation Structures that converge on their own Real collaborative editing (shared-document style) A lot of complexity; for text editors

Last write wins is acceptable when the changes are atomic and infrequent, and it is what most simple applications do. But it loses data without warning, and on a work board that breeds distrust: "I set it to high, who changed it?".

The sane option for Nómada Tasks is optimistic version control, the real-time equivalent of HTTP's ETag / If-Match:

// The client sends which version it believed it had
channel.send('task:updated', {
  id: 6,
  priority: 'medium',
  baseVersion: task.version        // ← 4, the one it had when Marta pressed
});
// The server (pseudocode, so the rule is clear)
if (taskInDb.version !== message.payload.baseVersion) {
  reply({ type: 'error', payload: {
    code: 'conflict',
    id: message.payload.id,
    current: taskInDb            // returns the real state
  }});
} else {
  taskInDb.version += 1;
  save(taskInDb);
  broadcastToAll({ type: 'task:updated', payload: taskInDb, meta: { version: taskInDb.version } });
}

And on the client, a conflict is handled without losing anybody's work:

document.addEventListener(EVENTS.REMOTE_ERROR, (event) => {
  const { code, id, current } = event.detail;
  if (code !== 'conflict') return;

  const mine = board.findById(id);
  view.showConflict({
    message: `"${current.title}" was modified by someone else while you were editing it.`,
    options: [
      { text: 'See the current version', onPress: () => { board.replace(Task.fromJSON(current)); view.render(); } },
      { text: 'Overwrite with mine',     onPress: () => channel.send('task:updated', { ...mine.toJSON(), baseVersion: current.version }) }
    ]
  });
});

The golden rule: a conflict detected and shown is always better than a change silently lost. And an important warning: the version is controlled by the server, never by the client. If the client could decide the version, the mechanism would be worthless.

  1. Security: wss, authentication and validation

WebSockets have a security profile of their own, and three non-negotiable rules.

Always use wss://. Over ws:// everything travels in the clear: messages, tokens, data. What is more, a page served over HTTPS cannot open a ws:// —the browser blocks it as mixed content— so in production it is not optional.

Authenticate the connection, and be careful how. The WebSocket constructor does not accept headers: you cannot set Authorization: Bearer. The real options:

Method How Catch
Session cookie The browser sends it in the handshake Requires the same site and a properly configured SameSite
Token in the URL wss://…/board?token=abc It ends up in the server's and the proxy's logs. Use single-use, short-lived tokens
Authentication message Connect and send {type:'auth', token} as the first message The server must close if it does not arrive within N seconds
Subprotocol new WebSocket(url, ['bearer', token]) A widespread trick, but it abuses the field

The cleanest option is usually the third: connect, authenticate with the first message and have the server close with 4001 if a valid credential does not arrive within a few seconds.

ALWAYS validate on the server. This is the most important one and the most ignored. An open WebSocket is a channel through which the client can send whatever it likes, and anybody can open one with a single line from the console. Everything that comes in through the channel is untrusted input, exactly like a form:

  • Check authorization on every message, not just at connection time. Iván being allowed to see the board does not mean he may delete Lucía's tasks.
  • Validate the shape and the content of every payload. The model's rules R1-R10 are applied on the server, not just in the browser.
  • Check the origin in the handshake: the browser sends the Origin header, and the server must reject the ones it does not recognize. WebSockets are not subject to the same-origin policy of CORS, so without this check any website could connect using the user's cookies. That attack has a name: Cross-Site WebSocket Hijacking.
  • Limit message size and frequency (rate limiting): a malicious client can try to exhaust the server's memory.
  • Never paint content that arrived through the channel with innerHTML. It is another user's input, and the XSS from 06-02 is lying in wait just the same.

And the compliance warning that runs through the whole module: a real-time channel carrying real personal data (names, locations, messages between people) falls within the scope of data protection regulations, with obligations around encryption, minimization and retention. Marta, Iván and Lucía are fictional; your users are not.

  1. Nómada Tasks: js/data/realtime.js

All together now, in a class with the same philosophy as the rest of the data/ layer: it does not know the DOM, it does not know the view, it only translates between the server and the application's events.

// js/data/realtime.js
import { EVENTS, emit } from '../view/events.js';
import { Task } from '../model/task.js';

const STATES = Object.freeze({
  CLOSED: 'closed', CONNECTING: 'connecting', OPEN: 'open', GAVE_UP: 'gaveUp'
});

export class BoardChannel extends EventTarget {
  #url;
  #socket = null;
  #state = STATES.CLOSED;
  #sender = crypto.randomUUID();          // identifies THIS client, so the echo can be ignored
  #queue = [];
  #closedOnPurpose = false;
  #attempts = 0;
  #retryId = null;
  #heartbeat = { interval: null, watchdog: null };

  static MAX_ATTEMPTS = 10;
  static MAX_QUEUE = 50;
  static HEARTBEAT_MS = 25000;
  static PONG_WAIT_MS = 10000;

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

  get state()     { return this.#state; }
  get connected() { return this.#socket?.readyState === WebSocket.OPEN; }
  get pending()   { return this.#queue.length; }

  // ─────────────────────────── connection ───────────────────────────

  connect() {
    if (this.#state === STATES.CONNECTING || this.connected) return;

    if (!navigator.onLine) {                                   // no network: wait, do not spend attempts
      window.addEventListener('online', () => this.connect(), { once: true });
      return;
    }

    this.#closedOnPurpose = false;
    this.#setState(STATES.CONNECTING);

    this.#socket = new WebSocket(this.#url);
    this.#socket.addEventListener('open', () => this.#onOpen());
    this.#socket.addEventListener('message', (e) => this.#onMessage(e));
    this.#socket.addEventListener('error', () => console.warn('[nomada] Error on the channel.'));
    this.#socket.addEventListener('close', (e) => this.#onClose(e));
  }

  /** Voluntary close: does NOT reconnect. */
  disconnect(reason = 'Board closed') {
    this.#closedOnPurpose = true;
    clearTimeout(this.#retryId);
    this.#stopHeartbeat();
    this.#socket?.close(1000, reason);
    this.#socket = null;
    this.#setState(STATES.CLOSED);
  }

  #onOpen() {
    this.#attempts = 0;
    this.#setState(STATES.OPEN);
    this.#startHeartbeat();

    this.send('subscribe', { board: 'taller-nomada', v: 1 });
    this.#flushQueue();
  }

  #onClose(event) {
    this.#stopHeartbeat();
    this.#socket = null;

    if (this.#closedOnPurpose || event.code === 1000) {
      this.#setState(STATES.CLOSED);
      return;
    }
    if (event.code === 4001) {                                 // invalid token: do not insist
      this.#setState(STATES.GAVE_UP);
      emit(document, EVENTS.REMOTE_ERROR, { code: 'session', message: 'Session expired.' });
      return;
    }
    this.#scheduleReconnect(event.code);
  }

  #scheduleReconnect(code) {
    if (this.#attempts >= BoardChannel.MAX_ATTEMPTS) {
      this.#setState(STATES.GAVE_UP);                          // the view will show "Retry"
      return;
    }
    const exponential = Math.min(500 * 2 ** this.#attempts, 30000);
    const delay = exponential + Math.random() * exponential * 0.3;   // jitter
    this.#attempts += 1;

    console.warn(`[nomada] Channel closed (${code}). Retry ${this.#attempts} in ${Math.round(delay)} ms.`);
    this.#setState(STATES.CLOSED);
    this.#retryId = setTimeout(() => this.connect(), delay);
  }

  // ─────────────────────────── messages ───────────────────────────

  send(type, payload = {}) {
    const message = {
      type, payload,
      meta: { id: crypto.randomUUID(), sender: this.#sender, ts: new Date().toISOString() }
    };

    if (this.connected) {
      this.#socket.send(JSON.stringify(message));
      return true;
    }
    if (this.#queue.length >= BoardChannel.MAX_QUEUE) this.#queue.shift();
    this.#queue.push(message);
    return false;                                              // the view marks it "not synced"
  }

  #flushQueue() {
    const pending = this.#queue;
    this.#queue = [];
    for (const message of pending) this.#socket.send(JSON.stringify(message));
  }

  #onMessage(event) {
    let message;
    try {
      message = JSON.parse(event.data);
    } catch {
      console.warn('[nomada] Non-JSON message discarded.');
      return;                                                  // a bad message does not bring the channel down
    }
    if (typeof message?.type !== 'string') return;
    if (message.meta?.sender === this.#sender) return;          // echo of my own changes

    this.#dispatch(message);
  }

  #dispatch(message) {
    switch (message.type) {
      case 'pong':
        clearTimeout(this.#heartbeat.watchdog);
        break;
      case 'state:full':
        emit(document, EVENTS.BOARD_UPDATED, {
          tasks: message.payload.tasks.map((d) => Task.fromJSON(d)), source: 'server'
        });
        break;
      case 'task:created':
      case 'task:updated':
        emit(document, EVENTS.TASK_CHANGED, {
          task: Task.fromJSON(message.payload), version: message.meta?.version, source: 'server'
        });
        break;
      case 'task:deleted':
        emit(document, EVENTS.TASK_DELETED, { id: message.payload.id, source: 'server' });
        break;
      case 'presence':
        emit(document, EVENTS.PRESENCE, { connected: message.payload.connected });
        break;
      case 'error':
        emit(document, EVENTS.REMOTE_ERROR, message.payload);
        break;
      default:
        console.warn('[nomada] Unknown type:', message.type);
    }
  }

  // ─────────────────────────── heartbeat ───────────────────────────

  #startHeartbeat() {
    this.#stopHeartbeat();
    this.#heartbeat.interval = setInterval(() => {
      if (!this.connected) return;
      this.#socket.send(JSON.stringify({ type: 'ping', meta: { sender: this.#sender } }));
      this.#heartbeat.watchdog = setTimeout(
        () => this.#socket?.close(4008, 'No response to the ping'),
        BoardChannel.PONG_WAIT_MS
      );
    }, BoardChannel.HEARTBEAT_MS);
  }

  #stopHeartbeat() {
    clearInterval(this.#heartbeat.interval);
    clearTimeout(this.#heartbeat.watchdog);
    this.#heartbeat.interval = this.#heartbeat.watchdog = null;
  }

  #setState(next) {
    if (this.#state === next) return;
    this.#state = next;
    this.dispatchEvent(new CustomEvent('state', { detail: { state: next } }));
  }
}

And its use in the application:

// js/app.js
import { BoardChannel } from './data/realtime.js';

const channel = new BoardChannel('wss://api.tallernomada.example/v1/board');

channel.addEventListener('state', (event) => {
  const { state } = event.detail;
  $('#connection-indicator').textContent = {
    open: 'Live', connecting: 'Connecting…',
    closed: 'No connection', gaveUp: 'No connection to the board'
  }[state];
  $('#connection-indicator').dataset.state = state;         // the CSS paints the colored dot
  $('#reconnect').hidden = state !== 'gaveUp';
});

channel.connect();

// Local changes travel through the channel as well as through the API
document.addEventListener(EVENTS.TASK_CHANGED, (event) => {
  if (event.detail.source === 'server') return;             // do not send back what came from outside
  channel.send('task:updated', event.detail.task.toJSON());
});

// Clean close when leaving the page
window.addEventListener('pagehide', () => channel.disconnect('Page closed'));

Notice that the class extends EventTarget: that lets it emit its own events ('state') with the same API as any DOM element, without depending on document. It is a very useful pattern for service objects.

  1. A minimal test server

To practice you need a server. With Node and the ws library, just enough for the board to work:

mkdir nomada-ws && cd nomada-ws
npm init -y
npm install ws
// server.js — MINIMAL test server. Not fit for production:
// no authentication, no validation, no persistence, no Origin check.
import { WebSocketServer } from 'ws';

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

let tasks = [
  { id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván', priority: 'high',
    status: 'in-progress', estimatedHours: 12, dueDate: '2026-09-30', version: 1 },
  { id: 6, title: 'Carpentry workshop quote', assignee: 'Iván', priority: 'high',
    status: 'pending', estimatedHours: 5, dueDate: '2026-09-05', version: 1 }
];

/** Forwards a message to every connected client. */
function broadcast(message) {
  const text = JSON.stringify(message);
  for (const client of server.clients) {
    if (client.readyState === 1) client.send(text);
  }
}

server.on('connection', (socket) => {
  console.log('Client connected. Total:', server.clients.size);

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

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

      case 'subscribe':
        socket.send(JSON.stringify({ type: 'state:full', payload: { tasks } }));
        broadcast({ type: 'presence', payload: { connected: server.clients.size } });
        break;

      case 'task:updated': {
        const current = tasks.find((t) => t.id === message.payload.id);
        if (!current) return;

        // Optimistic version control (section 13)
        if (message.payload.baseVersion !== undefined && message.payload.baseVersion !== current.version) {
          socket.send(JSON.stringify({
            type: 'error', payload: { code: 'conflict', id: current.id, current }
          }));
          return;
        }
        Object.assign(current, message.payload, { version: current.version + 1 });
        broadcast({ type: 'task:updated', payload: current,
                    meta: { sender: message.meta?.sender, version: current.version } });
        break;
      }
    }
  });

  socket.on('close', () => {
    broadcast({ type: 'presence', payload: { connected: server.clients.size } });
  });
});

console.log('Test server on ws://localhost:8080');
node server.js

And on the client, new BoardChannel('ws://localhost:8080'). Open two tabs with the application, move a card in one and watch it move in the other: that is the moment when you understand what all of this is for.

This server is a teaching toy. It is missing absolutely everything from section 14: authentication, per-message authorization, validation of the rules R1-R10, Origin checking, size and rate limits, and persistence. Do not deploy it.

To test the client without writing a server there are public echo services such as wss://echo.websocket.org or wss://ws.postman-echo.com/raw, which return whatever you send them. They are useful for verifying the connection, the reconnection and the message format, not the board logic.

Common Mistakes and Tips

  • Sending before the open. readyState is CONNECTING and send() throws InvalidStateError. Wait for the event or queue it.
  • Trying to reopen the same socket. CLOSED is terminal. Reconnecting means constructing a new WebSocket.
  • Putting the reconnection in the error handler. After an error a close always arrives; you would reconnect twice. It goes in close.
  • Reconnecting without exponential backoff. When the server restarts, a thousand clients in a loop will bring it down again.
  • Reconnecting after a 1000 or a 4001. The first was a requested close; the second, a credentials rejection. Insisting fixes neither.
  • Not implementing a heartbeat. The zombie connection is the hardest failure to diagnose: everything "works" and nothing arrives.
  • Passing objects to send(). They become '[object Object]'. Always JSON.stringify.
  • Parsing without try/catch. A corrupt message breaks the handler and can leave the channel useless.
  • Applying the echo of your own messages. It causes flicker and visible intermediate states. Filter by meta.sender.
  • Using ws:// in production. Everything travels in the clear and the browser blocks it from an HTTPS page.
  • Trusting what comes in through the channel. It is user input. Validate on the server, and on the client never paint it with innerHTML.
  • Forgetting to close when leaving the page. It leaves connections hanging on the server. Use pagehide.
  • Tip: look at the frames in DevTools. Network → WS filter → Messages tab: you will see every message sent and received with its timestamp. It is the main diagnostic tool.
  • Tip: always show the connection state. A green/amber/red dot with text costs ten lines and stops the user thinking the application is broken.
  • Tip: test by turning the wifi off. It is the only way of knowing whether your reconnection, your queue and your heartbeat really work.
  • Tip: if you only receive, consider SSE. It reconnects on its own, goes through proxies and is far less code.

Exercises

Exercise 1 — Queue with deduplication and persistence. Extend the BoardChannel queue so that: (a) if two messages with the same type and the same payload.id get queued, only the last one is kept; (b) the queue is stored in localStorage under 'nomada:queue:v1' every time it changes, and is restored when the channel is constructed; (c) messages more than an hour old (according to meta.ts) are discarded when flushing, because resending a very old change can trample somebody else's more recent work.

Exercise 2 — Accessible connection indicator. Write connectIndicator(channel, element) that reflects the channel's state (connecting, open, closed, gaveUp) in a DOM element, with readable text, a data-state for the CSS and aria-live="polite" so it is announced. It must also show how many messages are pending in the queue when there is no connection, and offer a "Retry" button only in the gaveUp state that resets the attempts and connects again. Use { signal } so everything can be disconnected in one go.

Exercise 3 — Conflict resolution with versions. Implement applyRemoteChange(board, task, version) that applies a received change only if its version is greater than the one already held, and returns 'applied' | 'ignored' | 'conflict'. It counts as a conflict when the remote version is lower than the local one (our change is newer and the server does not know about it yet). Add sendWithVersion(channel, task) that includes baseVersion and, on an error message with code: 'conflict', shows the user the two options instead of deciding on its own.

Solutions

Solution 1

const QUEUE_KEY = 'nomada:queue:v1';
const MAX_AGE_MS = 3600000;      // 1 hour

#enqueue(message) {
  // (a) Deduplicate: out with the previous one of the same type and same resource
  const key = `${message.type}:${message.payload?.id ?? ''}`;
  this.#queue = this.#queue.filter((m) => `${m.type}:${m.payload?.id ?? ''}` !== key);

  this.#queue.push(message);
  if (this.#queue.length > BoardChannel.MAX_QUEUE) this.#queue.shift();
  this.#persistQueue();                                    // (b)
}

#persistQueue() {
  try {
    localStorage.setItem(QUEUE_KEY, JSON.stringify(this.#queue));
  } catch {
    /* out of quota: the queue is still alive in memory, which is what matters */
  }
}

#restoreQueue() {
  try {
    const stored = JSON.parse(localStorage.getItem(QUEUE_KEY) ?? '[]');
    this.#queue = Array.isArray(stored) ? stored : [];
  } catch {
    this.#queue = [];
  }
}

#flushQueue() {
  const now = Date.now();
  // (c) Discard what is too old: resending it would trample more recent work
  const fresh = this.#queue.filter((m) => now - Date.parse(m.meta.ts) < MAX_AGE_MS);
  const discarded = this.#queue.length - fresh.length;
  if (discarded > 0) console.warn(`[nomada] ${discarded} expired messages discarded.`);

  this.#queue = [];
  this.#persistQueue();
  for (const message of fresh) this.#socket.send(JSON.stringify(message));
}

Deduplicating by type:id is what stops you resending five status changes for the same card when only the last one matters. And discarding by age is a product decision disguised as code: a change from three hours ago should almost never overwrite the current state, and if it did, it would be the worst kind of data loss.

Solution 2

const LABELS = Object.freeze({
  connecting: 'Connecting…',
  open:       'Live',
  closed:     'No connection',
  gaveUp:     'No connection to the board'
});

export function connectIndicator(channel, element, { signal } = {}) {
  const text   = element.querySelector('.indicator__text');
  const button = element.querySelector('.indicator__retry');

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

  function paint(state) {
    const pending = channel.pending;
    const suffix = (state !== 'open' && pending > 0)
      ? ` · ${pending} ${pending === 1 ? 'change not sent' : 'changes not sent'}`
      : '';

    text.textContent = LABELS[state] + suffix;        // textContent, never innerHTML (06-02)
    element.dataset.state = state;                    // the CSS paints the colored dot
    button.hidden = state !== 'gaveUp';
  }

  channel.addEventListener('state', (event) => paint(event.detail.state), { signal });

  button.addEventListener('click', () => {
    channel.resetAttempts();                          // new method: sets #attempts back to 0
    channel.connect();
  }, { signal });

  paint(channel.state);                                // initial state, without waiting for the first change
  return () => paint(channel.state);                   // lets you refresh the pending counter
}

The signal lets you unmount the whole indicator with a single abort(), exactly as you learned in 07-03. And role="status" with aria-live="polite" makes the state change announce itself without interrupting: losing the connection is relevant information for someone who cannot see the colored dot too.

Solution 3

export function applyRemoteChange(board, remoteTask, remoteVersion) {
  const local = board.findById(remoteTask.id);

  if (local === undefined) {                       // we did not have it: it is new to us
    board.add(remoteTask);
    return 'applied';
  }

  const localVersion = local.version ?? 0;

  if (remoteVersion > localVersion) {
    board.replace(remoteTask);
    return 'applied';
  }
  if (remoteVersion === localVersion) {
    return 'ignored';                              // we already have it, it is the echo of a known change
  }
  return 'conflict';                               // ours is newer than the server's
}
export function sendWithVersion(channel, task) {
  return channel.send('task:updated', { ...task.toJSON(), baseVersion: task.version });
}

document.addEventListener(EVENTS.REMOTE_ERROR, (event) => {
  const { code, id, current } = event.detail;
  if (code !== 'conflict') return;

  const mine = board.findById(id);
  view.showConflict({
    message: `"${current.title}" changed while you were editing it.`,
    options: [
      { text: 'Keep the server version',
        onPress: () => { board.replace(Task.fromJSON(current)); view.render(); } },
      { text: 'Overwrite with mine',
        onPress: () => channel.send('task:updated', { ...mine.toJSON(), baseVersion: current.version }) }
    ]
  });
});

The essential thing is not the algorithm but the design decision: the code does not choose on the user's behalf. When there is a real conflict, it is shown, with the two versions and two buttons. The 'ignored' case matters just as much: without it, the echo of a change you already applied would cause an unnecessary repaint and, with optimistic UI in the mix, a visible flicker.

Conclusion

The Taller Nómada board is alive now. You know why the request-response model is not enough —the server cannot speak first— and you know the four techniques that solve it, with the criteria for choosing: polling when latency does not matter, long polling when compatibility rules, SSE when you only receive (it reconnects on its own and is far less code) and WebSockets when you need both directions with low latency. You understand the protocol: the HTTP handshake with Upgrade and the 101 Switching Protocols, the ws:// and wss:// schemes, the full-duplex channel where there are no longer verbs or status codes, and the real cost it entails —the server keeps state per connection, and that complicates scaling.

You have mastered the API: the constructor that connects immediately, the four events, readyState with its four values and the rule that CLOSED is terminal —reconnecting means building a new socket—, send() that only accepts text or binary, event.data that never arrives parsed, and close(code, reason) with its vocabulary of codes, from the clean 1000 to the 1006 that screams "reconnect" and the 4000-4999 range that is yours. You have designed a message protocol with type, payload and meta, because a WebSocket is a pipe of bytes and the structure is yours to put in; and you have aligned it with the names of the CustomEvents from 06-04 so there is a single vocabulary.

You know that a real connection drops, and you have the three defenses: reconnection with exponential backoff and jitter, which does not insist after a 1000 or a 4001; a heartbeat ping/pong with a watchdog to catch the zombie connection the browser does not detect; and a message queue with a cap, deduplication and discard-by-age so that a change made offline is not silently lost. You know how to integrate the channel with the view without coupling them —the channel emits CustomEvents, the view listens and reconciles by data-id as in 06-06, the repository from 07-01 keeps the local copy up to date— and to filter out the echo of your own changes by meta.sender so the optimistic UI from 07-03 does not flicker. And you know that conflicts are a product problem: last write wins loses data silently, while optimistic version control detects them and lets the person decide, which is always better.

On security three rules are engraved: wss mandatory, real authentication —remembering that the constructor does not accept headers and that a token in the URL ends up in the logs—, and above all always validate on the server, checking the Origin in the handshake, because WebSockets are not subject to the same-origin policy and without that check any website could connect with your user's credentials.

With js/data/realtime.js and its BoardChannel, Nómada Tasks is a connected, robust and collaborative application. It has one big weakness left, and it is the most everyday one of all: it depends entirely on having a network. If Iván goes down to the screen-printing storeroom, where the wifi does not reach, the application does not start: the HTML, the CSS and the ES modules live on the server, and without a connection the browser has nothing to load. The localStorage copy is there, but nobody can read it because the page does not even open. To cross that boundary something different is needed: a process that lives outside the page, that intercepts network requests before they go out and knows how to answer them from a cache. That is Service Workers and Progressive Web Apps (PWAs), where Nómada Tasks will learn to work offline and to install itself on the device like any other application.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved