Build a Last-Seen Presence Service

Build a local WebSocket presence service with heartbeats, privacy, and sessions.

Introduction

30 Second Summary

A chat status feels dependable until a sleeping laptop or broken connection leaves someone looking online long after they are gone. That tiny label hides the hard problem of proving that a person is still there.

In this project, you will build a local WhatsApp-style last-seen service using Node.js and WebSocket connections. Two Safari tabs let you reproduce a ghost-online failure before heartbeat supervision restores trustworthy status updates.

What You'll Build

In two Safari tabs, Bob watches Alice move from online to last seen just now after a silent connection failure.

By the end of this project, you'll have:

  • A live presence dashboard that shows online, last seen just now, and minute-based relative time from a server-owned timestamp.
  • A deterministic silent-failure test that exposes ghost online before heartbeat timeouts remove the unresponsive session.
  • A privacy-aware user status that keeps Alice online while one session survives. It also withholds her status when either user's Nobody rule denies the read.
  • Secret Mission: Turn the local Maps into a production architecture map using Redis, durable storage, and Pub/Sub without installing infrastructure.

Are there any prerequisites?

This project assumes comfort with backend JavaScript and real-time systems.

The first step checks Node.js, npm, Visual Studio Code, and Safari on your Mac before you build.

Before We Start

You are building a WhatsApp-style presence service to learn why a socket connection alone cannot prove that a user is online. The finished design must survive a silent connection failure where no WebSocket close event reaches the server.

Set Up the Local Presence Lab

Reliable timing experiments need a predictable local runtime. A pinned WebSocket library keeps later presence behavior tied to one known dependency version.

Node.js runs the lab on your Mac. This step prepares the small local scaffold that every presence experiment builds on.

Visual Studio Code gives you one place to manage the project files. Safari provides the separate browser tabs you will use for Alice and Bob.

In this step, get ready to:
  • Confirm the local Node.js toolchain is ready.
  • Create the ~/presence-lab scaffold with its starter files.
  • Install the pinned dependency and run the verification script.
Verify the local tools

The lab supports Node.js 22 or 24. Both are LTS lines for this project.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter.
  • Check the installed Node.js version by running:
node --version

✔️ I see a supported LTS version

Your output starts with v22 or v24. Your Node.js runtime is ready for the lab.

ⓧ I see an older version

Install the current Node.js 24 LTS release to replace the older system installation.

  • Download the macOS installer from the official Node.js download page.
  • Open the downloaded .pkg file.
  • Complete the installation prompts.
  • Quit Terminal after the installation finishes.
  • Press Cmd+Space to open Spotlight again.
  • Type Terminal and press Enter.
  • Check the updated version by running:
node --version

You should now see output starting with v24.

ⓧ Command not found

Node.js is missing from your Mac. The official macOS installer adds the runtime needed by this project.

  • Download Node.js 24 LTS from the official Node.js download page.
  • Open the downloaded .pkg file.
  • Complete the installation prompts.
  • Quit Terminal after the installation finishes.
  • Press Cmd+Space to open Spotlight again.
  • Type Terminal and press Enter.
  • Verify the installation by running:
node --version

You should now see output starting with v24.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code and press Enter.

You will see a Visual Studio Code window. The editor is ready for your project files.

  • Press Cmd+Space to return to Spotlight.
  • Type Safari and press Enter.

You will see a Safari window. Keep it available for the browser dashboard you build next.

Create the starter project

The project separates server code from the browser dashboard. The empty public directory reserves the dashboard's location before you build it.

  • Return to Terminal.
  • Create ~/presence-lab with its public directory by running these commands:
mkdir -p ~/presence-lab/public
cd ~/presence-lab

What do these commands do?

  • The mkdir -p command creates both directories in your home folder.
  • The cd command moves Terminal into ~/presence-lab for the remaining project commands.
  • Switch back to Visual Studio Code.
  • Click File in the menu bar.
  • Select Open Folder....
  • Select presence-lab from your home folder.
  • Click Open.
  • Choose Yes, I trust the authors if Workspace Trust appears.

You should see presence-lab in the Explorer view. Its empty public directory confirms the scaffold exists.

Can't find the project folder?

Check that Terminal accepted the directory command without a permissions error. The folder should sit directly inside your macOS home folder.

Make sure you selected presence-lab itself instead of its public directory.

Help me find the presence-lab folder I created in Terminal.

The package.json file defines the start task. It also locks the WebSocket library to the version used throughout the lab.

  • Click New File... in the Explorer view.
  • Enter package.json as the file name.
  • Define the project metadata by pasting this code:
{
  "name": "presence-lab",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "ws": "8.21.3"
  }
}

What does this file do?

  • The name field identifies the local project.
  • The private field protects the lab from accidental publication.
  • The scripts.start field points the start task at server.js.
  • The dependencies field pins ws to 8.21.3.
  • Save package.json.
  • Confirm package.json is listed beside the public directory in the Explorer view.

Seeing a JSON warning?

Check every double quote and comma against the code block. JSON rejects missing commas or trailing commas.

Confirm the dependency value is exactly 8.21.3.

Help me fix my package.json file.

✔️ Awesome, I've got everything!

Great. Your project metadata now pins the exact dependency used by the lab.

ⓧ I'd like to double check the full code

{
  "name": "presence-lab",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "ws": "8.21.3"
  }
}

The temporary server script gives the setup a concrete runtime check. Its single log line proves Node.js can execute the project's start task.

  • Click New File... in the Explorer view.
  • Enter server.js as the file name.
  • Add the temporary verification script by pasting this code:
console.log('Presence lab setup complete');

What does this code do?

This temporary script prints one confirmation line when Node.js runs it. The next step replaces it with the first presence server.

  • Save server.js.
  • Confirm server.js appears beside package.json in the Explorer view.

Can't save server.js?

Confirm the file sits directly inside presence-lab. It should not sit inside the public directory.

Check that the string uses matching single quotes.

Help me create the temporary server.js script.

✔️ Awesome, I've got everything!

Your two starter files are saved. The project is ready for its local dependency installation.

ⓧ I'd like to double check the full code

console.log('Presence lab setup complete');
Install and verify the local dependency

The ws library provides the server-side WebSocket implementation used later. Installing it locally keeps the dependency inside ~/presence-lab.

  • Return to the Terminal window positioned in ~/presence-lab.
  • Install the dependency from package.json by running:
npm install
  • Confirm node_modules appears in the Visual Studio Code Explorer view.
  • Confirm package-lock.json appears in the same view.

That installation confirms npm is available. The generated lock file records the project-local ws dependency at 8.21.3.

Did the installation fail?

Confirm Terminal is still inside ~/presence-lab. The command needs the package.json file in its current directory.

Check your internet connection if the download stops before the dependency is installed.

Help me install ws in presence-lab.

Before you run the start task, which confirmation line do you expect the temporary script to print?

  • Run the project start task with this command:
npm start

You should see Presence lab setup complete in Terminal. That's the foundation in place: your pinned local project can now run through its start task.

Don't see the setup message?

Confirm Terminal is inside ~/presence-lab. Check that package.json points its start task at server.js.

Compare server.js with the full-code tab above if the command reports a JavaScript syntax problem.

Help me run the presence lab starter script.

Your local lab is ready to run Node.js code with the pinned WebSocket dependency. Next, you will build disconnect-only presence and expose the ghost-online failure.

Expose Ghost-Online Presence

Your local Node.js project is ready. It now needs an HTTP page plus a WebSocket channel that carries presence updates between connected users.

A clean exit appears easy because Safari sends a close event. A silent failure exposes the real problem because the server receives no close event and leaves Alice stuck online.

In this step, get ready to:
  • Build a disconnect-only presence server with server-owned last-seen timestamps.
  • Create a two-user browser dashboard with live relative presence text.
  • Compare a clean disconnect with the ghost-online result from a silent failure.
Build the disconnect-only server

The server treats an open socket as evidence that a user is online. It writes lastSeenAt only when that socket emits a clean close event.

Why use plain ws?

The ws package exposes the connection lifecycle directly. That visibility lets you test what the server can infer from a connection and what remains unknowable without a close event.

  • Switch back to Visual Studio Code from earlier.
  • Select the public directory in the Explorer sidebar.
  • Click the New File button in the Explorer toolbar.
  • Enter index.html as the file name.
  • Create the dashboard structure by pasting this code into public/index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Presence Lab</title>
</head>
<body>
  <main>
    <h1>Presence Lab</h1>
    <div class="grid">
      <label>I am <input id="userId" value="alice"></label>
      <label>Session ID <input id="sessionId" value="phone"></label>
      <label>Watch user <input id="watchUserId" value="bob"></label>
    </div>
    <div class="actions">
      <button id="connectButton">Connect</button>
      <button id="disconnectButton">Clean disconnect</button>
      <button id="silentFailureButton">Simulate Silent Failure</button>
    </div>
    <section class="card">
      <div id="connectionText"></div>
      <div id="presenceText"></div>
      <div id="diagnosticsText"></div>
    </section>
    <h2>Event log</h2>
    <div id="eventLog"></div>
  </main>
  <script>
  </script>
</body>
</html>

What does this structure provide?

  • The three inputs identify the current user session plus the contact being watched.
  • The action buttons expose connection and failure controls for the experiment.
  • The status area keeps the connection state separate from the watched contact's presence result.
  • Save public/index.html.
  • Confirm index.html now appears inside the public directory in the Explorer sidebar.

Can't find index.html?

Check that index.html sits inside ~/presence-lab/public. A file beside the public directory cannot be loaded by this server.

Ask for help if the file appears in a different directory. Help me place index.html inside my presence-lab public directory.

  • Select server.js in the Explorer sidebar.
  • Replace the temporary verification script with this HTTP server:
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import WebSocket, { WebSocketServer } from 'ws';

const PORT = 8080;
const page = await readFile(new URL('./public/index.html', import.meta.url));
const users = new Map();

const server = http.createServer((request, response) => {
  if (request.url === '/') {
    response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    response.end(page);
    return;
  }

  response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
  response.end('Not found');
});

const wss = new WebSocketServer({ server });

server.listen(PORT, () => {
  console.log(`Presence lab: http://localhost:${PORT}`);
});

What does this server do?

  • The HTTP server reads public/index.html once when the process starts.
  • Requests to / receive the dashboard page.
  • The WebSocketServer shares the same local server on port 8080.

Before you start the server, what page do you expect the local URL to return?

  • Save server.js.
  • Return to the Terminal window from the setup step.
  • Start the local server by running:
npm start

What does this command do?

The command runs the start script from package.json. That script launches server.js with Node.js.

You should see Presence lab: http://localhost:8080 in Terminal. The process stays active because it is waiting for browser connections.

  • Switch back to Safari from earlier.
  • Visit http://localhost:8080 in the address bar.

You should see the Presence Lab heading plus three inputs and three controls. This proves the HTTP path can deliver your dashboard.

Does the page fail to load?

Confirm Terminal still shows the running server. Check that you started it from ~/presence-lab so it can find public/index.html.

A port error means another local process already uses port 8080. Stop that process before restarting the presence lab.

Ask for help with the exact Terminal output. Help me diagnose why my local presence server does not load.

The HTTP path works. The next two additions turn that static page into a disconnect-only presence service.

  • Return to server.js in Visual Studio Code.
  • Find const users = new Map();.
  • Add these presence helpers below that line:
function normalizeId(value, fallback) {
  const normalized = String(value ?? '')
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9_-]/g, '');
  return normalized || fallback;
}

function getOrCreateUser(userId) {
  if (!users.has(userId)) {
    users.set(userId, {
      socket: null,
      lastSeenAt: null
    });
  }
  return users.get(userId);
}

function sendJson(socket, payload) {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify(payload));
  }
}

function sendPresence(socket) {
  const subjectId = socket.meta.watchUserId;
  const subject = users.get(subjectId);
  const presence = subject ? { subjectId, visibility: 'visible', online: Boolean(subject.socket), lastSeenAt: subject.lastSeenAt, activeSessions: subject.socket ? 1 : 0 } : { subjectId, visibility: 'unavailable', online: false, lastSeenAt: null, activeSessions: 0 };
  sendJson(socket, { type: 'presence', ...presence });
}

How is presence represented?

  • The users map stores one socket plus one server-owned timestamp for each user.
  • The socket determines whether the user currently appears online.
  • The sendPresence() helper sends only the watched user's current record to each viewer.
  • Find const wss = new WebSocketServer({ server });.
  • Add this connection lifecycle below that line:
function broadcastPresence() {
  for (const client of wss.clients) {
    sendPresence(client);
  }
}

wss.on('connection', (socket, request) => {
  const requestUrl = new URL(request.url, 'http://localhost');
  const userId = normalizeId(requestUrl.searchParams.get('user'), 'alice');
  const sessionId = normalizeId(requestUrl.searchParams.get('session'), 'browser');
  const watchUserId = normalizeId(requestUrl.searchParams.get('watch'), userId === 'alice' ? 'bob' : 'alice');
  const user = getOrCreateUser(userId);

  if (user.socket) user.socket.terminate();
  socket.meta = { userId, sessionId, watchUserId };
  user.socket = socket;
  console.log(`${userId}/${sessionId} connected`);
  socket.on('error', console.error);

  socket.on('close', () => {
    if (user.socket !== socket) return;
    user.socket = null;
    user.lastSeenAt = Date.now();
    console.log(`${userId}/${sessionId} removed: socket closed; active sessions: 0`);
    broadcastPresence();
  });

  broadcastPresence();
});

What happens during a connection?

  • The query parameters identify the user session plus the contact being watched.
  • A new connection becomes the user's one active socket.
  • The close handler clears that socket and records the server's current time with Date.now().
  • The broadcast loop pushes the updated result to every connected watcher.

Before you restart, do you expect the server to remain available on the same local URL?

  • Save server.js.
  • Return to the running Terminal process.
  • Press Control-C to stop the old process.
  • Restart the updated server by running:
npm start

Why restart the process?

This server does not reload itself when server.js changes. Restarting makes Node.js execute the new connection and close handlers.

You should see the same local URL in Terminal. The process is now waiting for WebSocket connections as well as HTTP requests.

Does the restarted server exit?

A syntax error usually points to a missing brace around one of the new functions. Compare the named line with the full file below.

An import error can mean Terminal is outside ~/presence-lab or the local dependency is unavailable.

Share the first error line when asking for help. Help me fix my disconnect-only WebSocket server.

✔️ Awesome, I've got everything!

Your disconnect-only server is running. Keep this Terminal process active while you build the browser behavior.

ⓧ I'd like to double check the full code

import http from 'node:http';
import { readFile } from 'node:fs/promises';
import WebSocket, { WebSocketServer } from 'ws';

const PORT = 8080;
const page = await readFile(new URL('./public/index.html', import.meta.url));
const users = new Map();

function normalizeId(value, fallback) {
  const normalized = String(value ?? '')
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9_-]/g, '');
  return normalized || fallback;
}

function getOrCreateUser(userId) {
  if (!users.has(userId)) {
    users.set(userId, {
      socket: null,
      lastSeenAt: null
    });
  }
  return users.get(userId);
}

function sendJson(socket, payload) {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify(payload));
  }
}

function sendPresence(socket) {
  const subjectId = socket.meta.watchUserId;
  const subject = users.get(subjectId);
  const presence = subject ? { subjectId, visibility: 'visible', online: Boolean(subject.socket), lastSeenAt: subject.lastSeenAt, activeSessions: subject.socket ? 1 : 0 } : { subjectId, visibility: 'unavailable', online: false, lastSeenAt: null, activeSessions: 0 };
  sendJson(socket, { type: 'presence', ...presence });
}

const server = http.createServer((request, response) => {
  if (request.url === '/') {
    response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    response.end(page);
    return;
  }

  response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
  response.end('Not found');
});

const wss = new WebSocketServer({ server });

function broadcastPresence() {
  for (const client of wss.clients) {
    sendPresence(client);
  }
}

wss.on('connection', (socket, request) => {
  const requestUrl = new URL(request.url, 'http://localhost');
  const userId = normalizeId(requestUrl.searchParams.get('user'), 'alice');
  const sessionId = normalizeId(requestUrl.searchParams.get('session'), 'browser');
  const watchUserId = normalizeId(requestUrl.searchParams.get('watch'), userId === 'alice' ? 'bob' : 'alice');
  const user = getOrCreateUser(userId);

  if (user.socket) user.socket.terminate();
  socket.meta = { userId, sessionId, watchUserId };
  user.socket = socket;
  console.log(`${userId}/${sessionId} connected`);
  socket.on('error', console.error);

  socket.on('close', () => {
    if (user.socket !== socket) return;
    user.socket = null;
    user.lastSeenAt = Date.now();
    console.log(`${userId}/${sessionId} removed: socket closed; active sessions: 0`);
    broadcastPresence();
  });

  broadcastPresence();
});

server.listen(PORT, () => {
  console.log(`Presence lab: http://localhost:${PORT}`);
});

What should match?

Your full file should contain the HTTP route followed by the WebSocket connection lifecycle. It should not contain heartbeat timers or timeout handling yet.

Add live browser presence

The browser opens a native WebSocket with the user and watched-contact identifiers in its URL. Incoming presence records stay absolute until the renderer converts the server timestamp into readable relative text.

  • Return to public/index.html in Visual Studio Code.
  • Place the following dashboard state inside the empty script element:
const userInput = document.querySelector('#userId');
const sessionInput = document.querySelector('#sessionId');
const watchInput = document.querySelector('#watchUserId');
const connectionText = document.querySelector('#connectionText');
const presenceText = document.querySelector('#presenceText');
const diagnosticsText = document.querySelector('#diagnosticsText');
const eventLog = document.querySelector('#eventLog');

let socket = null;
let heartbeatsPaused = false;
let latestPresence = null;

function normalize(value, fallback) {
  const normalized = value
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9_-]/g, '');
  return normalized || fallback;
}

function log(message) {
  const time = new Date().toLocaleTimeString();
  eventLog.textContent = `[${time}] ${message}\n${eventLog.textContent}`;
}

function sendJson(payload) {
  if (socket?.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify(payload));
  }
}

connectionText.textContent = 'This tab is disconnected';
log('Dashboard ready');

What does this browser state hold?

  • The element references give each status update one stable destination.
  • The socket variable holds this tab's current connection.
  • The event log adds a local timestamp to each browser-side event.
  • Save public/index.html.
  • Refresh the Presence Lab page in Safari.

You should see This tab is disconnected in the status card. The event log should contain a Dashboard ready entry.

Is the dashboard still blank?

Check that the new JavaScript sits between the opening and closing script tags. Confirm each selector matches an existing element ID.

Ask for help with the first browser error. Help me debug my dashboard initialization code.

  • Add these rendering functions below sendJson() inside the same script:
function formatLastSeen(lastSeenAt) {
  if (!lastSeenAt) return 'last seen unavailable';
  const elapsedMinutes = Math.floor(Math.max(0, Date.now() - lastSeenAt) / 60000);
  if (elapsedMinutes < 1) return 'last seen just now';
  const unit = elapsedMinutes === 1 ? 'minute' : 'minutes';
  return `last seen ${elapsedMinutes} ${unit} ago`;
}

function renderPresence() {
  if (!latestPresence) {
    presenceText.textContent = 'No presence result yet';
    diagnosticsText.textContent = 'No server diagnostics yet';
    return;
  }

  if (latestPresence.visibility === 'unavailable') {
    presenceText.textContent = `${latestPresence.subjectId}: no presence record`;
    diagnosticsText.textContent = 'The watched user has not connected yet';
    return;
  }

  presenceText.textContent = latestPresence.online
    ? `${latestPresence.subjectId}: online`
    : `${latestPresence.subjectId}: ${formatLastSeen(latestPresence.lastSeenAt)}`;
  const timestamp = latestPresence.lastSeenAt
    ? new Date(latestPresence.lastSeenAt).toISOString()
    : 'not set';
  diagnosticsText.textContent = `active sessions: ${latestPresence.activeSessions} | server lastSeenAt: ${timestamp}`;
}

renderPresence();

Why keep the timestamp absolute?

The server owns the moment recorded in lastSeenAt. The browser calculates phrases such as last seen just now or minute-based text whenever it renders.

This keeps the stored fact stable while its human-readable presentation changes over time.

  • Save public/index.html.
  • Refresh the Presence Lab page in Safari.

You should now see No presence result yet plus No server diagnostics yet in the status card.

  • Add this connection function below renderPresence(); inside the script:
function connect() {
  const user = normalize(userInput.value, 'alice');
  const session = normalize(sessionInput.value, 'browser');
  const watch = normalize(watchInput.value, user === 'alice' ? 'bob' : 'alice');
  const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
  const query = new URLSearchParams({ user, session, watch });
  heartbeatsPaused = false;
  socket = new WebSocket(`${protocol}://${location.host}/?${query}`);
  connectionText.textContent = `Connecting ${user}/${session}`;

  socket.addEventListener('open', () => {
    connectionText.textContent = `Connected as ${user}/${session}`;
    log(`Connected as ${user}/${session}; watching ${watch}`);
  });

  socket.addEventListener('message', (event) => {
    const message = JSON.parse(event.data);
    if (message.type === 'ping') {
      if (!heartbeatsPaused) sendJson({ type: 'pong' });
      return;
    }
    if (message.type === 'presence') {
      latestPresence = message;
      renderPresence();
    }
  });

  socket.addEventListener('close', () => {
    connectionText.textContent = `Disconnected ${user}/${session}`;
    log(`Connection closed for ${user}/${session}`);
  });
}

document.querySelector('#connectButton').addEventListener('click', connect);

How does a tab connect?

  • The input values become query parameters on the WebSocket URL.
  • The open event confirms the server accepted this tab's connection.
  • Each presence message replaces latestPresence before the dashboard renders again.
  • Save public/index.html.
  • Refresh the current Safari tab.
  • Open a second Safari tab.
  • Load http://localhost:8080 in the second tab.

Use the first tab as Bob. The second tab becomes Alice.

  • Enter bob in Bob's I am field.
  • Enter browser in Bob's Session ID field.
  • Enter alice in Bob's Watch user field.
  • Click Connect in Bob's tab.

Bob's tab should show Connected as bob/browser. Alice has no record until her tab connects.

  • Enter alice in Alice's I am field.
  • Enter phone in Alice's Session ID field.
  • Enter bob in Alice's Watch user field.
  • Click Connect in Alice's tab.

Bob should now see alice: online with one active session. Your two-user presence path is working.

Does Bob miss Alice's update?

Confirm both tabs show a connected status. Check that Bob's watched user is spelled alice before he connects.

Terminal should contain one connection log for each tab. A missing log means that tab never reached the server.

Ask for help with the tab values plus Terminal logs. Help me connect Alice and Bob to my local presence server.

Compare clean and silent failures

The two failure controls now need browser behavior. One closes the socket deliberately while the other leaves it open and stops the tab's participation.

  • Add these control handlers below the existing Connect event listener:
document.querySelector('#disconnectButton').addEventListener('click', () => {
  if (socket?.readyState === WebSocket.OPEN) {
    log('Sending a clean WebSocket close');
    socket.close();
  }
});

document.querySelector('#silentFailureButton').addEventListener('click', () => {
  if (socket?.readyState === WebSocket.OPEN) {
    heartbeatsPaused = true;
    connectionText.textContent = 'Silent failure simulated: socket open, pongs suppressed';
    log('Heartbeat responses paused without closing the socket');
  }
});

setInterval(renderPresence, 1000);

How do the controls differ?

The clean control calls socket.close(). That action gives the server a close event it can use to record lastSeenAt.

The silent-failure control keeps the socket open. The disconnect-only server therefore receives no event that changes Alice's record.

  • Save public/index.html.
  • Refresh Bob's tab.
  • Refresh Alice's tab.
  • Enter bob in Bob's I am field.
  • Enter browser in Bob's Session ID field.
  • Enter alice in Bob's Watch user field.
  • Click Connect in Bob's tab.

Bob is ready to watch Alice again.

  • Enter alice in Alice's I am field.
  • Enter phone in Alice's Session ID field.
  • Enter bob in Alice's Watch user field.
  • Click Connect in Alice's tab.

Bob should show alice: online before either failure test begins.

Before you click the clean-disconnect control, do you expect Bob to keep an online result or receive a last-seen value?

  • Click Clean disconnect in Alice's tab.

Bob should change to alice: last seen just now. The diagnostic timestamp should contain the time recorded by the server.

That clean path works. Bob now receives a last-seen value because Alice's close event reached the server.

  • Click Connect in Alice's tab again.

Bob should return to alice: online.

Before you trigger the silent failure, do you think Bob's view can change without a close event?

  • Click Simulate Silent Failure in Alice's tab.

Alice's tab should report that the socket remains open. Bob incorrectly continues to show alice: online.

This failure is intentional

The server has no new signal to process, so its stored socket still looks active. You have reproduced the ghost-online failure that a disconnect-only design cannot resolve.

Does Alice disappear after the silent failure?

Make sure you clicked Simulate Silent Failure instead of Clean disconnect. The Alice tab should explicitly say that its socket remains open.

A browser refresh also closes the old connection. Repeat the test without refreshing either tab after Alice reconnects.

Ask for help if Terminal logs a socket close during the silent test. Help me reproduce the ghost-online state.

✔️ Awesome, I've got everything!

Your dashboard now proves both sides of the comparison. A clean close updates last-seen while a silent failure leaves Alice stuck online.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Presence Lab</title>
</head>
<body>
  <main>
    <h1>Presence Lab</h1>
    <div class="grid">
      <label>I am <input id="userId" value="alice"></label>
      <label>Session ID <input id="sessionId" value="phone"></label>
      <label>Watch user <input id="watchUserId" value="bob"></label>
    </div>
    <div class="actions">
      <button id="connectButton">Connect</button>
      <button id="disconnectButton">Clean disconnect</button>
      <button id="silentFailureButton">Simulate Silent Failure</button>
    </div>
    <section class="card">
      <div id="connectionText"></div>
      <div id="presenceText"></div>
      <div id="diagnosticsText"></div>
    </section>
    <h2>Event log</h2>
    <div id="eventLog"></div>
  </main>
  <script>
    const userInput = document.querySelector('#userId');
    const sessionInput = document.querySelector('#sessionId');
    const watchInput = document.querySelector('#watchUserId');
    const connectionText = document.querySelector('#connectionText');
    const presenceText = document.querySelector('#presenceText');
    const diagnosticsText = document.querySelector('#diagnosticsText');
    const eventLog = document.querySelector('#eventLog');

    let socket = null;
    let heartbeatsPaused = false;
    let latestPresence = null;

    function normalize(value, fallback) {
      const normalized = value
        .trim()
        .toLowerCase()
        .replace(/[^a-z0-9_-]/g, '');
      return normalized || fallback;
    }

    function log(message) {
      const time = new Date().toLocaleTimeString();
      eventLog.textContent = `[${time}] ${message}\n${eventLog.textContent}`;
    }

    function sendJson(payload) {
      if (socket?.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify(payload));
      }
    }

    connectionText.textContent = 'This tab is disconnected';
    log('Dashboard ready');

    function formatLastSeen(lastSeenAt) {
      if (!lastSeenAt) return 'last seen unavailable';
      const elapsedMinutes = Math.floor(Math.max(0, Date.now() - lastSeenAt) / 60000);
      if (elapsedMinutes < 1) return 'last seen just now';
      const unit = elapsedMinutes === 1 ? 'minute' : 'minutes';
      return `last seen ${elapsedMinutes} ${unit} ago`;
    }

    function renderPresence() {
      if (!latestPresence) {
        presenceText.textContent = 'No presence result yet';
        diagnosticsText.textContent = 'No server diagnostics yet';
        return;
      }

      if (latestPresence.visibility === 'unavailable') {
        presenceText.textContent = `${latestPresence.subjectId}: no presence record`;
        diagnosticsText.textContent = 'The watched user has not connected yet';
        return;
      }

      presenceText.textContent = latestPresence.online
        ? `${latestPresence.subjectId}: online`
        : `${latestPresence.subjectId}: ${formatLastSeen(latestPresence.lastSeenAt)}`;
      const timestamp = latestPresence.lastSeenAt
        ? new Date(latestPresence.lastSeenAt).toISOString()
        : 'not set';
      diagnosticsText.textContent = `active sessions: ${latestPresence.activeSessions} | server lastSeenAt: ${timestamp}`;
    }

    renderPresence();

    function connect() {
      const user = normalize(userInput.value, 'alice');
      const session = normalize(sessionInput.value, 'browser');
      const watch = normalize(watchInput.value, user === 'alice' ? 'bob' : 'alice');
      const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
      const query = new URLSearchParams({ user, session, watch });
      heartbeatsPaused = false;
      socket = new WebSocket(`${protocol}://${location.host}/?${query}`);
      connectionText.textContent = `Connecting ${user}/${session}`;

      socket.addEventListener('open', () => {
        connectionText.textContent = `Connected as ${user}/${session}`;
        log(`Connected as ${user}/${session}; watching ${watch}`);
      });

      socket.addEventListener('message', (event) => {
        const message = JSON.parse(event.data);
        if (message.type === 'ping') {
          if (!heartbeatsPaused) sendJson({ type: 'pong' });
          return;
        }
        if (message.type === 'presence') {
          latestPresence = message;
          renderPresence();
        }
      });

      socket.addEventListener('close', () => {
        connectionText.textContent = `Disconnected ${user}/${session}`;
        log(`Connection closed for ${user}/${session}`);
      });
    }

    document.querySelector('#connectButton').addEventListener('click', connect);

    document.querySelector('#disconnectButton').addEventListener('click', () => {
      if (socket?.readyState === WebSocket.OPEN) {
        log('Sending a clean WebSocket close');
        socket.close();
      }
    });

    document.querySelector('#silentFailureButton').addEventListener('click', () => {
      if (socket?.readyState === WebSocket.OPEN) {
        heartbeatsPaused = true;
        connectionText.textContent = 'Silent failure simulated: socket open, pongs suppressed';
        log('Heartbeat responses paused without closing the socket');
      }
    });

    setInterval(renderPresence, 1000);
  </script>
</body>
</html>

What should match?

Your complete dashboard should contain the three inputs plus the three controls. Its script should connect users, render relative time, close cleanly, and preserve the socket during the silent-failure test.

You've reproduced the failure instead of designing around it. Next, you'll give the server an independent liveness deadline so a silent Alice can expire.

Add Heartbeat Supervision

Your disconnect-only service exposed the ghost-online failure. Alice stays online whenever her WebSocket never delivers a close event.

Heartbeat supervision gives the Node.js server an independent liveness deadline. The server can now expire an unresponsive session without waiting for the transport to report a clean disconnect.

In this step, get ready to:
  • Track the latest heartbeat response for each connected session.
  • Make the browser respond to server heartbeat messages.
  • Confirm that a silent failure produces server-owned last-seen text.
Add the server heartbeat deadline

An application heartbeat is a small message sent at a regular interval. A fresh response proves that the session can still participate in the presence protocol.

  • Return to server.js in Visual Studio Code.
  • Find the existing PORT constant near the top of the file.
  • Add the heartbeat interval and session timeout below it using this block:
const PORT = 8080;
const HEARTBEAT_INTERVAL_MS = 2000;
const SESSION_TIMEOUT_MS = 6500;

What do these timers control?

  • HEARTBEAT_INTERVAL_MS makes the server check its sessions every two seconds.
  • SESSION_TIMEOUT_MS gives a session 6.5 seconds to provide a fresh response.
  • The short timings make the failure observable during the lab.
  • Find the connection handler that records the connected session.
  • Update its session registration to store the socket and the first heartbeat timestamp using this block:
socket.meta = { userId, sessionId, watchUserId };
user.sessions.set(sessionId, {
  socket,
  lastPongAt: Date.now()
});

What does this session state do?

Each connected session receives its own lastPongAt value. The initial server timestamp gives a new connection time to answer its first ping.

  • Find the existing message handler inside the connection callback.
  • Add this branch immediately after the message has been parsed:
if (message.type === 'pong') {
  const session = user.sessions.get(sessionId);
  if (session?.socket === socket) {
    session.lastPongAt = Date.now();
  }
  return;
}

How does a pong prove liveness?

A pong updates the timestamp only when the stored session still belongs to this socket. An older connection cannot refresh a replacement session.

  • Add this session-removal function above the HTTP server creation:
function removeSession(userId, sessionId, socket, reason) {
  const user = users.get(userId);
  const currentSession = user?.sessions.get(sessionId);

  if (!user || !currentSession || currentSession.socket !== socket) {
    return;
  }

  user.sessions.delete(sessionId);

  if (user.sessions.size === 0) {
    user.lastSeenAt = Date.now();
  }

  console.log(
    `${userId}/${sessionId} removed: ${reason}; active sessions: ${user.sessions.size}`
  );
  broadcastPresence();
}

Why does the server set lastSeenAt?

The server calls Date.now() when it decides that the user has gone offline. Every watcher therefore receives the same authoritative timestamp.

The browser keeps that absolute value in memory. It converts the value into relative text only while rendering the status.

  • Add the heartbeat sweep below the connection handler:
const heartbeatSweep = setInterval(() => {
  const now = Date.now();

  for (const [userId, user] of users) {
    for (const [sessionId, session] of user.sessions) {
      if (now - session.lastPongAt > SESSION_TIMEOUT_MS) {
        removeSession(userId, sessionId, session.socket, 'heartbeat timeout');
        session.socket.terminate();
        continue;
      }

      sendJson(session.socket, { type: 'ping' });
    }
  }
}, HEARTBEAT_INTERVAL_MS);

How does the heartbeat sweep work?

  • The sweep compares the current server time with each session's latest pong time.
  • An expired session is removed from presence state before its socket is terminated.
  • A responsive session receives another application-level ping message.
  • Save server.js.

Seeing a server syntax error?

Check that every heartbeat block sits inside the same scope as the identifiers it uses. Confirm that the session registration contains lastPongAt with the same capitalization.

Review the closing braces around the heartbeat sweep. The interval value belongs after the sweep callback.

Help me check my heartbeat server changes.

Reply to heartbeat pings

The server now asks each browser session to prove that it is responsive. A healthy browser answers every application-level ping with a pong.

  • Switch back to public/index.html in Visual Studio Code.
  • Find the existing WebSocket message listener.
  • Add this heartbeat branch immediately after the incoming message is parsed:
if (message.type === 'ping') {
  if (!heartbeatsPaused) {
    sendJson({ type: 'pong' });
  }
  return;
}

What does the browser response do?

A normal tab answers every ping through the existing sendJson() helper. The silent-failure control sets heartbeatsPaused to true so the socket remains open without sending pongs.

That controlled silence gives the server a deterministic failure to detect.

  • Save public/index.html.
  • Switch back to the terminal running the presence service.
  • Stop the running process.
  • Restart the presence service by running:
npm start

What does this restart load?

npm start runs the start script from package.json. The restarted process loads the new heartbeat timers and serves the updated browser dashboard.

  • Return to the Alice tab in Safari from earlier.
  • Refresh the Alice tab.
  • Click Connect in the Alice tab.
  • Return to the Bob tab in Safari from earlier.
  • Refresh the Bob tab.
  • Click Connect in the Bob tab.
  • Leave both tabs connected for longer than eight seconds.

Bob continues to see Alice as online. The stable status proves that Alice is answering the server's heartbeat messages.

Do healthy tabs disconnect after eight seconds?

Confirm that the ping branch is inside the WebSocket message listener. Check that it appears before the presence-message branch.

Make sure heartbeatsPaused is false when a new connection starts.

Help me stop healthy sessions from timing out.

Retest the silent failure

A clean connection now stays alive through regular pongs. The final check removes those responses while leaving Alice's socket open.

Before you trigger the failure, how do you expect Bob's status to change when Alice stops answering heartbeats?

  • Click Simulate Silent Failure in the Alice tab.
  • Watch Alice's status from the Bob tab for about eight seconds.

Bob first sees Alice as online. The status then changes to last seen just now after the heartbeat deadline expires.

  • Leave the Bob tab open for one minute.

Bob's display updates to minute-based last-seen text. The underlying lastSeenAt value remains the absolute timestamp supplied by the server.

What did the timeout prove?

The missing close event no longer traps Alice in a ghost-online state. The server uses the absence of fresh pongs as an independent failure signal.

The heartbeat deadline controls when Alice becomes offline. The browser controls only how the server-owned timestamp is displayed.

You cleared the ghost-online failure with a server-owned liveness deadline. Next, you'll make every presence read pass through a privacy decision before Bob can see it.

Protect Presence with Privacy Rules

Heartbeat supervision now gives Bob accurate liveness information about Alice. That result still needs an authorization decision before the server shares it.

In this step, you will add privacy-aware reads to the WebSocket service. The server will withhold online state, timestamps, and session diagnostics when either user's rule denies access.

In this step, get ready to:
  • Store an everyone or nobody privacy value for every user.
  • Route every outbound presence result through a server-side visibility check.
  • Verify subject-side hiding and reciprocal viewer-side hiding.
Store privacy and protect presence reads

The server owns the complete presence record. A privacy gate can therefore remove sensitive fields before a result leaves the server.

  • Switch back to server.js in Visual Studio Code.
  • Find the user record inside getOrCreateUser().
  • Add the default privacy value beside lastSeenAt so those lines look like this:
      lastSeenAt: null,
      privacy: 'everyone'

Why start with everyone?

Each user now has an explicit privacy rule. The everyone value preserves the dashboard behavior you already tested.

A visibility result has three possible states. It can be visible, hidden, or unavailable.

  • Add the first part of getVisiblePresence() immediately below getOrCreateUser():
function getVisiblePresence(viewerId, subjectId) {
  const viewer = users.get(viewerId);
  const subject = users.get(subjectId);

  if (!subject) {
    return {
      subjectId,
      visibility: 'unavailable',
      online: false,
      lastSeenAt: null,
      activeSessions: 0
    };
  }

What does this code do?

  • The viewer is the user asking for a presence result.
  • The subject is the user being watched.
  • The unavailable result handles a subject who has never connected.
  • Complete the same function by adding this code directly below the previous block:

  const denied =
    viewerId !== subjectId &&
    (subject.privacy === 'nobody' || viewer?.privacy === 'nobody');

  if (denied) {
    return {
      subjectId,
      visibility: 'hidden'
    };
  }

  return {
    subjectId,
    visibility: 'visible',
    online: subject.sessions.size > 0,
    lastSeenAt: subject.lastSeenAt,
    activeSessions: subject.sessions.size
  };
}

How does the privacy gate work?

Cross-user reads are denied when the subject uses nobody. They are also denied when the viewer uses nobody.

A hidden result contains only the subject identifier and visibility state. The sensitive presence fields never reach the browser.

  • Find the existing sendPresence() function.
  • Replace that function with this privacy-aware version:
function sendPresence(socket) {
  const presence = getVisiblePresence(
    socket.meta.userId,
    socket.meta.watchUserId
  );
  sendJson(socket, { type: 'presence', ...presence });
}

What does this function protect?

Every watcher update now passes through getVisiblePresence(). This makes the server's outbound path the enforcement point.

  • Save server.js.
  • Return to the Terminal panel running the presence service.
  • Stop the current server with the terminal interruption shortcut.

Before you restart the server, do you expect the existing online view to keep working with both users on the default privacy setting?

  • Restart the presence service by running:
npm start

What should you see?

The start script launches the updated server. The terminal should show the local presence lab address without a syntax error.

  • Return to the Alice tab in Safari.
  • Click Connect to reconnect Alice.
  • Return to the Bob tab in Safari.
  • Click Connect to reconnect Bob.

Bob should still see Alice as online. Good progress. The server now checks privacy without changing the default experience.

Server does not restart?

Check that both pieces of getVisiblePresence() are adjacent. The final closing brace must appear before sendJson().

Check that sendPresence() passes socket.meta.userId before socket.meta.watchUserId.

Help me debug my privacy-aware presence function.

Add the privacy selector

The dashboard needs a control for changing the current user's rule. The browser sends the selected value to the server instead of deciding which presence fields to hide.

  • Return to public/index.html in Visual Studio Code.
  • Find the grid containing the user, session, and watched-contact inputs.
  • Add this label after the watched-contact input:
      <label>
        Last-seen privacy
        <select id="privacy">
          <option value="everyone">Everyone</option>
          <option value="nobody">Nobody</option>
        </select>
      </label>

What does this control represent?

The selector exposes the lab's two simplified privacy modes. Everyone shares presence while Nobody denies cross-user reads.

  • Save public/index.html.
  • Refresh either Safari tab.

You should see a Last-seen privacy selector with Everyone selected.

  • Find the browser references near userInput and watchInput.
  • Add the privacy selector reference below watchInput:
    const privacySelect = document.querySelector('#privacy');

Why keep this reference?

The privacySelect reference gives the connection setup and change listener access to the selected value.

  • Find the beginning of renderPresence().
  • Add this hidden-result branch after the empty-result check:
      if (latestPresence.visibility === 'hidden') {
        presenceText.textContent = `${latestPresence.subjectId}: last seen hidden`;
        diagnosticsText.textContent = 'Session count and timestamp were withheld by the server';
        return;
      }

Why render the visibility state?

The browser receives an explicit hidden result from the server. It does not receive online, lastSeenAt, or activeSessions and then conceal them locally.

  • Find the URLSearchParams object inside connect().
  • Add the selected privacy value after watch so those lines look like this:
        watch,
        privacy: privacySelect.value

What happens during connection?

Each new connection includes the current privacy choice. The server stores that value before broadcasting presence results.

  • Save public/index.html.
  • Refresh the Alice tab in Safari.
  • Select Nobody from Alice's Last-seen privacy selector.
  • Click Connect in the Alice tab.
  • Reconnect Bob with Everyone selected.

Bob should see alice: last seen hidden. The diagnostics should say that the session count and timestamp were withheld by the server.

  • Find the existing privacySelect event listeners near the bottom of the script.
  • Add this change listener below the watched-user listener:
    privacySelect.addEventListener('change', () => {
      sendJson({ type: 'privacy', value: privacySelect.value });
      log(`Privacy changed to ${privacySelect.value}`);
    });

What does the listener change?

The selector now updates privacy over the active socket. The server immediately recalculates every watcher's visible result.

  • Return to server.js in Visual Studio Code.
  • Find the message.type === 'pong' branch inside the socket message handler.
  • Add this privacy branch immediately after the pong branch:
    if (
      message.type === 'privacy' &&
      (message.value === 'everyone' || message.value === 'nobody')
    ) {
      user.privacy = message.value;
      broadcastPresence();
      return;
    }

Why broadcast after an update?

A privacy change can alter what several connected watchers are allowed to see. Broadcasting recalculates each result through getVisiblePresence().

  • Save server.js.
  • Save public/index.html.
  • Return to the Terminal panel running the presence service.
  • Stop the current server with the terminal interruption shortcut.

Before you restart, do you expect changing a privacy selector to update the other tab without reconnecting either user?

  • Restart the updated service by running:
npm start

What does the restart load?

The running process now includes the privacy message handler. Fresh browser connections also load the completed selector logic.

  • Refresh both Safari tabs.
  • Click Connect in the Alice tab.
  • Click Connect in the Bob tab.

Both tabs should connect normally. Bob should see Alice as online while both selectors show Everyone.

Privacy selector has no effect?

Check that the selector uses id="privacy". The browser reference must query the same identifier.

Check that the server accepts only the lowercase values everyone and nobody.

Help me debug my live privacy updates.

Verify reciprocal privacy

A privacy rule can deny a read from either side. The subject controls who sees their presence while the viewer's rule applies the reciprocal restriction.

Before you test Alice's rule, what do you expect Bob to receive when Alice selects Nobody?

  • Select Nobody in Alice's Last-seen privacy selector.

Bob should immediately see alice: last seen hidden. Bob should also see that the server withheld the session count and timestamp.

  • Restore Alice's selector to Everyone.

Bob should immediately see Alice as online again.

Before you test reciprocity, do you expect Bob to keep seeing Alice after Bob selects Nobody?

  • Select Nobody in Bob's Last-seen privacy selector.

Bob should see alice: last seen hidden again. This time Bob's own reciprocal rule denies the read.

How does reciprocity protect presence?

The visible WhatsApp rule ties access to sharing. Someone who does not share last seen cannot see another contact's last seen.

This lab models that user-facing behavior with two simplified choices. It does not claim to reproduce WhatsApp's internal architecture.

Privacy now protects every presence read on the server. Next, you will aggregate multiple Alice sessions without changing her last-seen timestamp too early.

Aggregate User Sessions

Your privacy-aware heartbeat service currently treats one WebSocket connection as the whole user. That model breaks as soon as Alice opens a second device session.

Alice should remain online while any session stays active. Her server-owned timestamp should change only when the final session disappears.

In this step, get ready to:
  • Store each session in a per-user Map.
  • Route connection lifecycle events to the correct session.
  • Prove the one-to-zero presence transition with three Safari tabs.
Refactor users into session collections

Each user record needs a collection of sessions. The user stays online while that collection contains at least one live session.

  • Return to server.js in Visual Studio Code.
  • Replace the existing getOrCreateUser() function with this version:
function getOrCreateUser(userId) {
  if (!users.has(userId)) {
    users.set(userId, {
      sessions: new Map(),
      lastSeenAt: null,
      privacy: 'everyone'
    });
  }
  return users.get(userId);
}

What does this user record hold?

  • The sessions Map groups every active device session under one user identifier.
  • The lastSeenAt value remains user-level state.
  • The privacy value still protects every presence read for that user.
  • Replace the opening of getVisiblePresence() through its unavailable-user branch with this code:
function getVisiblePresence(viewerId, subjectId) {
  const viewer = users.get(viewerId);
  const subject = users.get(subjectId);

  if (!subject) {
    return {
      subjectId,
      visibility: 'unavailable',
      online: false,
      lastSeenAt: null,
      activeSessions: 0
    };
  }

Why keep the unavailable result?

A watched user might have no server record yet. The result reports zero active sessions without inventing a timestamp.

  • Replace the remaining privacy decision and visible result inside getVisiblePresence() with this code:
  const denied =
    viewerId !== subjectId &&
    (subject.privacy === 'nobody' || viewer?.privacy === 'nobody');

  if (denied) {
    return {
      subjectId,
      visibility: 'hidden'
    };
  }

  return {
    subjectId,
    visibility: 'visible',
    online: subject.sessions.size > 0,
    lastSeenAt: subject.lastSeenAt,
    activeSessions: subject.sessions.size
  };
}

How does aggregation affect reads?

  • The online result comes from whether subject.sessions.size is greater than zero.
  • The activeSessions value exposes the authorized diagnostic count.
  • A hidden result still withholds the online state. It also withholds the timestamp.
  • Replace the existing session-removal helper with this removeSession() function:
function removeSession(userId, sessionId, socket, reason) {
  const user = users.get(userId);
  const currentSession = user?.sessions.get(sessionId);

  if (!user || !currentSession || currentSession.socket !== socket) {
    return;
  }

  user.sessions.delete(sessionId);

  if (user.sessions.size === 0) {
    user.lastSeenAt = Date.now();
  }

  console.log(
    `${userId}/${sessionId} removed: ${reason}; active sessions: ${user.sessions.size}`
  );
  broadcastPresence();
}

What does this removal rule protect?

  • The socket identity check prevents an old connection from deleting a newer session with the same identifier.
  • The helper deletes only the session that closed or timed out.
  • The server writes lastSeenAt only when sessions.size reaches 0.
  • Save server.js.
  • Check the Visual Studio Code Problems panel for syntax errors.

Seeing a problem in the session model?

Check that the property is named sessions in every edited function. Confirm that sessions is created with new Map().

Make sure Date.now() remains inside the zero-session condition. A timestamp written after every deletion would mark Alice offline too early.

Help me check my multi-session user model.

Target each connection lifecycle event

Heartbeat supervision now belongs to individual sessions. A phone can stop answering while a laptop continues to refresh its own liveness deadline.

  • Replace the beginning of the connection handler through the query-parameter reads with this code:
wss.on('connection', (socket, request) => {
  const requestUrl = new URL(request.url, 'http://localhost');
  const userId = normalizeId(requestUrl.searchParams.get('user'), 'alice');
  const sessionId = normalizeId(
    requestUrl.searchParams.get('session'),
    'browser'
  );
  const watchUserId = normalizeId(
    requestUrl.searchParams.get('watch'),
    userId === 'alice' ? 'bob' : 'alice'
  );
  const requestedPrivacy = requestUrl.searchParams.get('privacy');

Why read a session identifier?

The user identifier chooses the aggregate record. The session identifier chooses one device connection inside that record.

  • Replace the user-registration section below those query values with this code:
  const user = getOrCreateUser(userId);
  if (requestedPrivacy === 'everyone' || requestedPrivacy === 'nobody') {
    user.privacy = requestedPrivacy;
  }

  const previousSession = user.sessions.get(sessionId);
  if (previousSession) {
    previousSession.socket.terminate();
  }

  socket.meta = { userId, sessionId, watchUserId };
  user.sessions.set(sessionId, {
    socket,
    lastPongAt: Date.now()
  });

  console.log(`${userId}/${sessionId} connected`);
  socket.on('error', console.error);

How does registration stay deterministic?

  • A repeated session identifier terminates its previous socket before the replacement is stored.
  • The socket metadata carries the user identifier. It also carries the session identifier.
  • Each session receives its own lastPongAt deadline.
  • Replace the existing pong message branch with this session-specific branch:
    if (message.type === 'pong') {
      const session = user.sessions.get(sessionId);
      if (session?.socket === socket) {
        session.lastPongAt = Date.now();
      }
      return;
    }

What does each pong refresh?

A pong updates only the session that sent it. Activity from Alice's laptop cannot keep Alice's failed phone session alive.

  • Replace the socket close handler with this call to removeSession().
  socket.on('close', () => {
    removeSession(userId, sessionId, socket, 'socket closed');
  });

What does a clean close remove?

A clean close removes one named session. The aggregate user remains online when another session still exists.

  • Replace the existing heartbeat sweep with this per-session version:
const heartbeatSweep = setInterval(() => {
  const now = Date.now();

  for (const [userId, user] of users) {
    for (const [sessionId, session] of user.sessions) {
      if (now - session.lastPongAt > SESSION_TIMEOUT_MS) {
        removeSession(userId, sessionId, session.socket, 'heartbeat timeout');
        session.socket.terminate();
        continue;
      }

      sendJson(session.socket, { type: 'ping' });
    }
  }
}, HEARTBEAT_INTERVAL_MS);

How does the sweep aggregate sessions?

  • The outer loop visits each user.
  • The inner loop checks every session deadline for that user.
  • A timeout removes the failed session before terminating its socket.
  • The removal helper decides whether that deletion is the final-session transition.
  • Save server.js.
  • Switch back to the Terminal window running the presence service.
  • Stop the current process with Control+C.
  • Restart the completed service by running:
npm start

What does this restart prove?

The restart loads the refactored session model. You should see the local presence URL in the terminal without a startup error.

Your terminal should print Presence lab: http://localhost:8080.

Does the service stop during startup?

Check that every loop over user.sessions uses both a session identifier and a session object. Confirm that the heartbeat sweep closes with the existing interval value.

Check the first file and line number shown in Terminal. That location usually points to a missing brace from one of the replaced sections.

Help me start the multi-session presence server.

✔️ Awesome, I've got everything!

Great. Your server now tracks heartbeat deadlines per session. Keep it running for the three-tab test.

ⓧ I'd like to double check the full code

import http from 'node:http';
import { readFile } from 'node:fs/promises';
import WebSocket, { WebSocketServer } from 'ws';

const PORT = 8080;
const HEARTBEAT_INTERVAL_MS = 2000;
const SESSION_TIMEOUT_MS = 6500;

const page = await readFile(new URL('./public/index.html', import.meta.url));
const users = new Map();

function normalizeId(value, fallback) {
  const normalized = String(value ?? '')
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9_-]/g, '');
  return normalized || fallback;
}

function getOrCreateUser(userId) {
  if (!users.has(userId)) {
    users.set(userId, {
      sessions: new Map(),
      lastSeenAt: null,
      privacy: 'everyone'
    });
  }
  return users.get(userId);
}

function getVisiblePresence(viewerId, subjectId) {
  const viewer = users.get(viewerId);
  const subject = users.get(subjectId);

  if (!subject) {
    return {
      subjectId,
      visibility: 'unavailable',
      online: false,
      lastSeenAt: null,
      activeSessions: 0
    };
  }

  const denied =
    viewerId !== subjectId &&
    (subject.privacy === 'nobody' || viewer?.privacy === 'nobody');

  if (denied) {
    return {
      subjectId,
      visibility: 'hidden'
    };
  }

  return {
    subjectId,
    visibility: 'visible',
    online: subject.sessions.size > 0,
    lastSeenAt: subject.lastSeenAt,
    activeSessions: subject.sessions.size
  };
}

function sendJson(socket, payload) {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify(payload));
  }
}

function sendPresence(socket) {
  const presence = getVisiblePresence(
    socket.meta.userId,
    socket.meta.watchUserId
  );
  sendJson(socket, { type: 'presence', ...presence });
}

function broadcastPresence() {
  for (const client of wss.clients) {
    sendPresence(client);
  }
}

function removeSession(userId, sessionId, socket, reason) {
  const user = users.get(userId);
  const currentSession = user?.sessions.get(sessionId);

  if (!user || !currentSession || currentSession.socket !== socket) {
    return;
  }

  user.sessions.delete(sessionId);

  if (user.sessions.size === 0) {
    user.lastSeenAt = Date.now();
  }

  console.log(
    `${userId}/${sessionId} removed: ${reason}; active sessions: ${user.sessions.size}`
  );
  broadcastPresence();
}

const server = http.createServer((request, response) => {
  if (request.url === '/') {
    response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    response.end(page);
    return;
  }

  response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
  response.end('Not found');
});

const wss = new WebSocketServer({ server });

wss.on('connection', (socket, request) => {
  const requestUrl = new URL(request.url, 'http://localhost');
  const userId = normalizeId(requestUrl.searchParams.get('user'), 'alice');
  const sessionId = normalizeId(
    requestUrl.searchParams.get('session'),
    'browser'
  );
  const watchUserId = normalizeId(
    requestUrl.searchParams.get('watch'),
    userId === 'alice' ? 'bob' : 'alice'
  );
  const requestedPrivacy = requestUrl.searchParams.get('privacy');

  const user = getOrCreateUser(userId);
  if (requestedPrivacy === 'everyone' || requestedPrivacy === 'nobody') {
    user.privacy = requestedPrivacy;
  }

  const previousSession = user.sessions.get(sessionId);
  if (previousSession) {
    previousSession.socket.terminate();
  }

  socket.meta = { userId, sessionId, watchUserId };
  user.sessions.set(sessionId, {
    socket,
    lastPongAt: Date.now()
  });

  console.log(`${userId}/${sessionId} connected`);
  socket.on('error', console.error);

  socket.on('message', (data) => {
    let message;

    try {
      message = JSON.parse(String(data));
    } catch {
      return;
    }

    if (message.type === 'pong') {
      const session = user.sessions.get(sessionId);
      if (session?.socket === socket) {
        session.lastPongAt = Date.now();
      }
      return;
    }

    if (
      message.type === 'privacy' &&
      (message.value === 'everyone' || message.value === 'nobody')
    ) {
      user.privacy = message.value;
      broadcastPresence();
      return;
    }

    if (message.type === 'watch') {
      socket.meta.watchUserId = normalizeId(message.userId, watchUserId);
      sendPresence(socket);
    }
  });

  socket.on('close', () => {
    removeSession(userId, sessionId, socket, 'socket closed');
  });

  broadcastPresence();
});

const heartbeatSweep = setInterval(() => {
  const now = Date.now();

  for (const [userId, user] of users) {
    for (const [sessionId, session] of user.sessions) {
      if (now - session.lastPongAt > SESSION_TIMEOUT_MS) {
        removeSession(userId, sessionId, session.socket, 'heartbeat timeout');
        session.socket.terminate();
        continue;
      }

      sendJson(session.socket, { type: 'ping' });
    }
  }
}, HEARTBEAT_INTERVAL_MS);

wss.on('close', () => {
  clearInterval(heartbeatSweep);
});

server.listen(PORT, () => {
  console.log(`Presence lab: http://localhost:${PORT}`);
});
Prove the final-session transition

The decisive test uses Bob as a watcher plus two Alice sessions. Safari keeps each tab independent while the server aggregates both sessions under Alice.

  • Return to Safari.
  • Refresh the existing dashboard tab.
  • Choose File from the Safari menu bar.
  • Choose New Tab twice.
  • Load http://localhost:8080 in each new tab.

Your three test roles

The first tab represents Bob's watcher session. The other tabs represent Alice's phone session and laptop session.

  • Enter bob in the first tab's I am field.
  • Enter browser in its Session ID field.
  • Enter alice in its Watch user field.
  • Select Everyone from its Last-seen privacy menu.
  • Click Connect in the first tab.

Bob initially sees that Alice has no presence record. This gives you a clean baseline before either Alice session connects.

  • Enter alice in the second tab's I am field.
  • Enter phone in its Session ID field.
  • Enter bob in its Watch user field.
  • Select Everyone from its Last-seen privacy menu.
  • Click Connect in the second tab.

Bob now sees Alice as online with one active session.

  • Enter alice in the third tab's I am field.
  • Enter laptop in its Session ID field.
  • Enter bob in its Watch user field.
  • Select Everyone from its Last-seen privacy menu.
  • Click Connect in the third tab.

Bob should now see alice: online with active sessions: 2. The server timestamp should remain not set.

Before the first failure, do you expect Alice to become offline when only her phone session stops responding?

  • Click Simulate Silent Failure in Alice's phone tab.
  • Wait about eight seconds for the heartbeat deadline to expire.

Bob still sees alice: online with active sessions: 1. The server timestamp remains not set because Alice's laptop is still live.

Before the final failure, do you expect the server to preserve Alice's online state after her laptop stops responding?

  • Click Simulate Silent Failure in Alice's laptop tab.
  • Wait about eight seconds for the final heartbeat deadline to expire.

Bob now sees alice: last seen just now with active sessions: 0. The diagnostics also show the server-owned timestamp.

Does Alice go offline too early?

Check that the two Alice tabs use different Session ID values. Reusing one identifier causes the newer connection to replace the older session.

Confirm that lastSeenAt is assigned only inside the user.sessions.size === 0 condition. Confirm that both Alice tabs use Everyone privacy.

Help me debug the three-tab aggregation test.

That's the aggregation invariant proven. Alice stays online until her final live session disappears.

Secret mission

Map Presence to Redis and Pub/Sub

Your local lab makes presence decisions inside one process. In this mission, you will create ARCHITECTURE.md to map that logic to Redis, durable storage, and Pub/Sub. You will trace a silent failure through a production design with explicit delivery caveats.

Clean Up Your Resources

Clean Up Your Resources

Your presence lab runs entirely on your Mac at no ongoing cost. Choose whether to keep it running, pause it for later, or delete it entirely.

Resources you used:

  • The ~/presence-lab folder containing the completed source files.
  • The ~/presence-lab/ARCHITECTURE.md production architecture map.
  • The ~/presence-lab/node_modules directory containing the local dependencies.
  • The ~/presence-lab/package-lock.json generated dependency lock file.
  • The running Node.js presence service in Terminal.

Keep everything running

No action needed. This option suits you if you want to keep testing the completed presence lab.

  • Your files remain inside ~/presence-lab.
  • Your architecture map remains available in ~/presence-lab/ARCHITECTURE.md.
  • Your local dependencies remain available in ~/presence-lab/node_modules.
  • Your presence service remains available at http://localhost:8080 while its process is running.

Pause - I'll come back to this later

Pausing stops the local process while preserving every file. The in-memory presence state disappears when the process exits.

  • Switch back to the Terminal window running the presence service.
  • Press Control-C to stop the process.

The server releases port 8080. Your source files remain ready for another session.

  • Restart the presence service from the Terminal window for ~/presence-lab by running this command:
npm start

What Happens When You Restart?

  • The start script launches server.js through Node.js.
  • The service rebuilds its in-memory presence state from new browser connections.

You'll see Presence lab: http://localhost:8080 when the service is ready.

Presence Service Not Restarting?

  • Check that the Terminal prompt is inside ~/presence-lab.
  • Confirm that node_modules still exists inside the project folder.
  • Ask for help: Help me restart the local presence lab.

Delete - I don't want to use this again

Deleting removes the entire local lab from your Mac. Your Node.js installation stays untouched.

  • Switch back to the Terminal window running the presence service if it is still active.
  • Press Control-C to stop the process.
  • Remove ~/presence-lab by running this command:
rm -rf ~/presence-lab

What Does This Command Remove?

This removes the local project folder plus every file inside it. The deletion includes the source files, architecture map, installed dependencies, and generated package lock.

When the Terminal prompt returns without an error, the local presence lab has been removed.

Project Folder Still Present?

  • Check that the presence service has stopped before retrying the deletion.
  • Confirm that the command targets ~/presence-lab.
  • Ask for help: Help me safely remove the local presence lab.

Nice Work!

Nice Work!

You did it! You built a local presence lab with Node.js that tracks live users through native browser WebSocket connections.

You've learned how to:

  • Built a real-time presence dashboard with server-owned lastSeenAt values that render as relative text in Safari.
  • Reproduced the ghost-online failure with a silent session. Fixed it with heartbeat deadlines that expire unresponsive sessions.
  • Enforced reciprocal privacy on the server's read path. Aggregated multiple tabs or devices so lastSeenAt changes only on the final-session transition.
  • Secret Mission: Mapped ephemeral session state to expiring Redis records. Mapped server-owned last-seen timestamps to durable storage. Mapped cross-gateway fan-out to Pub/Sub with missed-message caveats. Required a coordinator or sweeper to decide the final-session transition.

Ready to quiz yourself?