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
- When request-response is not enough
- The four real-time techniques
- The protocol: from handshake to persistent channel
- The browser's
WebSocketAPI readyStateand the life cycle- Sending and receiving:
send,messageand JSON - A message protocol for Nómada Tasks
- Closing properly:
close(code, reason) - Reconnection with exponential backoff
- Heartbeat: detecting zombie connections
- Queuing messages while there is no connection
- Integrating with the view's
CustomEvents - Conflicts: two people, one task
- Security:
wss, authentication and validation - Nómada Tasks:
js/data/realtime.js - A minimal test server
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- 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 itThe 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.
- 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 Protocolsis 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://andwss://, analogous tohttp://andhttps://.wssis 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.
- The browser's
WebSocket API
WebSocket APIThe 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 theopen. Register them on the line right after the constructor, always. - The
errorevent does not say what happened. It is a security restriction, just like the generic CORSTypeErrorin 07-02. The useful diagnosis comes in theclosethat follows it, with itscode. - After an
erroraclosealways arrives. Do not put the reconnection logic in theerror: it would run twice. Put it in theclose.
readyState and the life cycle
readyState and the life cyclestateDiagram-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 OPENYou 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 11And 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).
- Sending and receiving:
send, message and JSON
send, message and JSONsend() 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.
- 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
typeper message, always present. The handler is aswitchontype, or an object of functions indexed by type; never a chain ofifs that guesses from the shape of the payload. - Version the protocol, just as you versioned the
localStorageformat in 07-01. Av: 1field in thesubscribewill let you change format without breaking old clients. - The payload must have the same shape as the REST API. That way
Task.fromJSONworks 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.
- Closing properly:
close(code, reason)
close(code, reason)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.
- 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.
- 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.
- 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.idavoids sending irrelevant history. - Persist it or not. An in-memory queue dies on reload. If the changes are valuable, store it in
localStoragewith the repository from 07-01. - Return whether it went out or not. The
return falselets the view mark the card as "pending sync", which is the honest thing to do (07-03).
- Integrating with the view's
CustomEvents
CustomEventsHere 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.
- 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.
- Security:
wss, authentication and validation
wss, authentication and validationWebSockets 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
Originheader, 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.
- Nómada Tasks:
js/data/realtime.js
js/data/realtime.jsAll 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.
- A minimal test server
To practice you need a server. With Node and the ws library, just enough for the board to work:
// 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');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,
Originchecking, 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.readyStateisCONNECTINGandsend()throwsInvalidStateError. Wait for the event or queue it. - Trying to reopen the same socket.
CLOSEDis terminal. Reconnecting means constructing a newWebSocket. - Putting the reconnection in the
errorhandler. After anerroraclosealways arrives; you would reconnect twice. It goes inclose. - Reconnecting without exponential backoff. When the server restarts, a thousand clients in a loop will bring it down again.
- Reconnecting after a
1000or a4001. 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]'. AlwaysJSON.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
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
