Build TabTalk WebSocket Chat

Build a local Node.js chat that broadcasts messages between browser tabs.

Introduction

30 Second Summary

A message feels instant when both sides can keep talking without repeatedly checking for updates. Two browser tabs make that hidden conversation easy to see.

In this project, you will build TabTalk, a two-tab chat powered by a Node.js server plus native browser WebSocket connections. You will turn a one-tab echo into a shared real-time channel with named messages plus visible connection feedback.

What You'll Build

Open TabTalk in two browser tabs, choose a different name in each, then watch every message appear instantly in both windows while the status badges track each connection.

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

  • A two-tab chat where you send a named message from either tab and see it appear immediately in both.
  • A broadcast message flow that carries structured JSON messages to every open connection, including the sender.
  • Visible connection lifecycle feedback that reports when each tab connects, encounters an error, or disconnects while keeping the send button safe.
  • A Secret Mission: turn the server's tracked connections into a live participant counter that updates as tabs join or leave.

Are there any prerequisites?

This project assumes advanced JavaScript familiarity. You also need Node.js, Visual Studio Code, plus Google Chrome or Microsoft Edge on Windows.

Before We Start

Each browser tab uses one persistent, full-duplex WebSocket connection to exchange chat messages in real time. This checkpoint locks in that two-tab goal before every connection decision becomes code.

Set Up the Local Project

TabTalk needs a local server before either browser tab can exchange messages. Your browser provides a native WebSocket client. The local Node.js process still needs a server-side implementation.

Visual Studio Code keeps the project files beside a PowerShell terminal in one workspace.

This step verifies Node.js v24.21.0 LTS. It also pins the ws library to version 8.21.3 for a repeatable server setup.

In this step, get ready to:
  • Create the tabtalk folder with its final package.json file.
  • Verify Node.js v24.21.0 LTS in PowerShell.
  • Install ws@8.21.3 as an exact dependency.
Create the project manifest

A project manifest tells npm which package belongs to TabTalk. Keeping it inside a named folder gives every later server file the same home.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.
  • Select File from the menu bar.
  • Select Open Folder....
  • Select Desktop in the folder dialog.
  • Select New Folder.

Why Keep the Folder on Your Desktop?

The Desktop gives your project a predictable location. You can return to the same tabtalk folder throughout the project.

Visual Studio Code may show a Workspace Trust prompt after the folder opens. Trusting the folder you created enables the editor's normal project features.

  • Enter tabtalk as the folder name.
  • Press Enter to create the folder.
  • Select the new tabtalk folder.
  • Select Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust prompt appears.

Your tabtalk folder is ready. You should see its name at the top of the Explorer view.

  • Select the Explorer view in the Activity Bar.
  • Select the New File... button.
  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Fill package.json by pasting this code:
{
  "name": "tabtalk",
  "version": "1.0.0",
  "dependencies": {
    "ws": "8.21.3"
  }
}

What Does This File Do?

  • The name field identifies this project as tabtalk.
  • The version field gives your app the starting version 1.0.0.
  • The dependencies field pins the server library to 8.21.3.
  • Press Ctrl+S to save package.json.
  • Confirm that package.json appears beneath tabtalk in the Explorer view.

That is your first project file in place. It gives npm an exact dependency target for the installation ahead.

Can't See package.json?

Check that the Explorer view shows tabtalk as its top folder. Recreate package.json inside that folder if it appears elsewhere.

Make sure the file name ends with .json. Windows can hide file extensions outside Visual Studio Code.

Help me fix my TabTalk package.json file.

✔️ Awesome, I've got everything!

Your saved manifest now matches the project configuration.

ⓧ I'd like to double check the full code

{
  "name": "tabtalk",
  "version": "1.0.0",
  "dependencies": {
    "ws": "8.21.3"
  }
}
Verify the Node.js version

The local server depends on a confirmed runtime before it can load the pinned package. PowerShell provides a direct check of the Node.js version currently available to your project.

  • Select View from the Visual Studio Code menu bar.
  • Select Terminal to open the integrated terminal.
  • Confirm that the terminal profile is PowerShell.
  • Check the installed Node.js version by running this command:
node --version

What Does This Command Do?

The node --version command asks the installed Node.js runtime to print its version. That output decides whether your setup can continue or needs an update.

✔️ I see the required version

PowerShell prints v24.21.0. Your runtime is ready for TabTalk.

ⓧ I see an older version

An older runtime needs an update before the dependency installation. The official Windows installer brings your existing Node.js installation to the required LTS release.

  • Open the official Node.js download page.
  • Download the Windows installer for Node.js v24.21.0 LTS.
  • Run the downloaded installer.
  • Follow the installer prompts to complete the update.
  • Return to Visual Studio Code from earlier.
  • Select Terminal from the menu bar.
  • Select New Terminal to load the updated runtime.
  • Recheck the installed version by running this command:
node --version

Why Open a New Terminal?

A terminal opened before the update can retain the old environment. A new PowerShell session loads the updated Node.js installation.

PowerShell should now print v24.21.0.

ⓧ Command not found

PowerShell cannot find a Node.js installation. Installing the current LTS release adds the runtime required by this project.

  • Open the official Node.js download page.
  • Download the Windows installer for Node.js v24.21.0 LTS.
  • Run the downloaded installer.
  • Follow the installer prompts to complete the installation.
  • Return to Visual Studio Code from earlier.
  • Select Terminal from the menu bar.
  • Select New Terminal to load the new installation.
  • Check the installed version by running this command:
node --version

What Confirms the Installation?

The command confirms that PowerShell can find the Node.js executable. A result of v24.21.0 means the required runtime is available.

Still Seeing the Old Version?

Make sure you opened a new PowerShell terminal after the installer finished. A terminal left open during the update can continue using the older environment.

Help me get PowerShell to use Node.js v24.21.0.

Install the exact WebSocket dependency

The browser uses its native WebSocket object. The Node.js server uses the pinned ws package to accept those browser connections.

Before you install it, what evidence do you expect to appear in the project when npm succeeds?

  • Install the exact ws dependency from the PowerShell terminal by running this command:
npm install ws@8.21.3 --save-exact

What Does This Command Do?

  • The npm command installs the published ws package into node_modules.
  • The --save-exact flag preserves version 8.21.3 without a version range.
  • The installation records the resolved dependency state in package-lock.json.
  • Wait for PowerShell to return to its prompt.
  • Confirm that npm reports a successful installation.
  • Confirm that node_modules appears in the Explorer view.
  • Confirm that package-lock.json appears beside package.json.

You should now see node_modules in the Explorer view. You should also see package-lock.json beside package.json.

Your local setup is complete. TabTalk now has its required runtime plus an exact server dependency.

Dependency Installation Failed?

Check that the terminal path points to your tabtalk folder. The Explorer view should show the same folder above package.json.

Confirm that node --version still prints v24.21.0. Retry the installation after restoring your internet connection if npm could not reach the package registry.

Help me troubleshoot the ws installation.

Your local foundation is ready. Next, you'll build the first browser-to-server message loop.

Build an Echo Chat

Your Node.js setup can now host the first working slice of TabTalk. This step connects an HTTP page to a local server so a message can make a complete round trip.

You will start with one persistent WebSocket connection. Once that loop works, a second browser tab will test how the server handles separate connections.

In this step, get ready to:
  • Serve a minimal TabTalk page from a local Node.js HTTP server.
  • Attach a WebSocket server that echoes each incoming message.
  • Test the echo in two browser tabs to reveal how separate connections behave.
Serve the first TabTalk page

The browser needs an HTML page before it can start a WebSocket connection. A small form gives you a visible interface that the message loop can build on.

  • Create index.html beside package.json using the file panel in Visual Studio Code.
  • Paste this initial page into index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>TabTalk</title>
</head>
<body>
  <h1>TabTalk</h1>
  <ul id="messages"></ul>
  <form id="chat-form">
    <input id="message" placeholder="Write a message" required>
    <button type="submit">Send</button>
  </form>
</body>
</html>

What does this page provide?

  • The messages list gives received messages somewhere to appear.
  • The chat-form form collects a message before submission.
  • The required input prevents an empty form submission.
  • Save index.html.
  • Confirm that index.html now appears beside package.json in the file list.

Cannot find the new page?

Check that index.html sits directly inside the open tabtalk folder. A nested copy will not match the path used by the server.

Help me locate index.html in my TabTalk project.

The server reads that page from disk whenever the Node.js process starts. It returns the page for the main route while giving every other route a simple missing-page response.

  • Create server.mjs beside index.html using the Visual Studio Code file panel.
  • Paste this first server version into server.mjs:
import { readFileSync } from 'node:fs';
import * as http from 'node:http';
import WebSocket, { WebSocketServer } from 'ws';

const html = readFileSync('./index.html');

const server = http.createServer((request, response) => {
  if (request.url !== '/') {
    response.writeHead(404, { 'Content-Type': 'text/plain' });
    response.end('Not found');
    return;
  }

  response.writeHead(200, { 'Content-Type': 'text/html' });
  response.end(html);
});

server.listen(8080, () => {
  console.log('TabTalk is running at http://localhost:8080');
});

What does this server do?

  • The imports provide file reading, HTTP server, and WebSocket server APIs.
  • The html constant holds the contents of index.html.
  • The request handler serves that HTML for the main route.
  • The listener keeps the server available on local port 8080.
  • Save server.mjs.
  • Confirm that the saved file appears beside index.html in the file list.

Before you start the server, what do you expect the browser to show when it receives the HTML from server.mjs?

  • Start the local server in the PowerShell terminal by running this command:
node server.mjs

What does this command do?

Node.js runs server.mjs and keeps the process active while the server listens for requests. PowerShell prints TabTalk is running at http://localhost:8080 when the listener is ready.

  • Switch to Google Chrome or Microsoft Edge.
  • Enter http://localhost:8080 in the address bar.

Good progress. You should see the TabTalk heading above a message field and a Send button.

Page not loading?

Check that PowerShell still shows the running server process. Confirm that index.html and server.mjs are in the same folder.

Help me debug why my local TabTalk page does not load.

Add the echo connection

The page now travels from the server to the browser. The next loop keeps one connection open so the browser can send a frame to the server and receive that same frame back.

  • Return to the PowerShell terminal from earlier.
  • Stop the running server by pressing Ctrl+C.
  • In server.mjs find the closing }); immediately above server.listen.
  • Insert this WebSocket server code above server.listen:
const wss = new WebSocketServer({ server });

wss.on('connection', (socket) => {
  socket.on('error', console.error);

  socket.on('message', (data, isBinary) => {
    socket.send(data, { binary: isBinary });
  });
});

How does the echo work?

  • The WebSocketServer attaches to the existing HTTP server.
  • The connection handler receives the socket for one browser connection.
  • The message handler keeps the frame data and its binary setting intact.
  • The socket.send() call writes the frame through the connection that delivered it.
  • Save server.mjs.
  • Confirm that the unsaved marker disappears from the server.mjs tab.

Unsure where the handler belongs?

Place the new block after the HTTP request handler closes. Keep it above the server.listen call.

Help me place the WebSocket echo handler in server.mjs.

✔️ Awesome, I've got everything!

Your complete server.mjs now serves the page and echoes frames through each connected socket.

ⓧ I'd like to double check the full code

import { readFileSync } from 'node:fs';
import * as http from 'node:http';
import WebSocket, { WebSocketServer } from 'ws';

const html = readFileSync('./index.html');

const server = http.createServer((request, response) => {
  if (request.url !== '/') {
    response.writeHead(404, { 'Content-Type': 'text/plain' });
    response.end('Not found');
    return;
  }

  response.writeHead(200, { 'Content-Type': 'text/html' });
  response.end(html);
});

const wss = new WebSocketServer({ server });

wss.on('connection', (socket) => {
  socket.on('error', console.error);

  socket.on('message', (data, isBinary) => {
    socket.send(data, { binary: isBinary });
  });
});

server.listen(8080, () => {
  console.log('TabTalk is running at http://localhost:8080');
});

How to compare this file

Check the import order first. Then compare the HTTP handler, WebSocket handler, and listener in sequence.

The browser client now needs to open its side of the connection. Its event handlers turn received frames into list items and turn form submissions into outgoing frames.

  • Return to index.html.
  • Find the blank line immediately above </body>.
  • Insert this script above </body>:
  <script>
    const form = document.querySelector('#chat-form');
    const messageInput = document.querySelector('#message');
    const messages = document.querySelector('#messages');
    const socket = new WebSocket(`ws://${window.location.host}`);

    socket.addEventListener('message', (event) => {
      const item = document.createElement('li');
      item.textContent = event.data;
      messages.append(item);
    });

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      socket.send(messageInput.value);
      messageInput.value = '';
    });
  </script>

What does the client script do?

  • The constants keep references to the form, input, and message list.
  • The native WebSocket uses the current page host to reach the attached server.
  • The message listener adds each received frame to the page with textContent.
  • The submit listener sends the input value before clearing the field.
  • Save index.html.
  • Confirm that the unsaved marker disappears from the index.html tab.

Script outside the page?

Keep the entire script element inside the document body. Its closing tag must remain directly above </body>.

Help me check the client script placement in index.html.

✔️ Awesome, I've got everything!

Your complete index.html now opens a socket and connects the form to the message list.

ⓧ 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>TabTalk</title>
</head>
<body>
  <h1>TabTalk</h1>
  <ul id="messages"></ul>
  <form id="chat-form">
    <input id="message" placeholder="Write a message" required>
    <button type="submit">Send</button>
  </form>

  <script>
    const form = document.querySelector('#chat-form');
    const messageInput = document.querySelector('#message');
    const messages = document.querySelector('#messages');
    const socket = new WebSocket(`ws://${window.location.host}`);

    socket.addEventListener('message', (event) => {
      const item = document.createElement('li');
      item.textContent = event.data;
      messages.append(item);
    });

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      socket.send(messageInput.value);
      messageInput.value = '';
    });
  </script>
</body>
</html>

How to compare this file

Check the page structure first. Then compare the socket creation, message listener, and submit listener in sequence.

Before you restart the server, what should happen when the form sends one message through the open socket?

  • Restart the local server in PowerShell by running this command:
node server.mjs

What confirms the restart?

PowerShell prints TabTalk is running at http://localhost:8080 again. The restarted process now includes the WebSocket handler you added.

  • Refresh the existing TabTalk browser tab.
  • Enter hello in the message field.
  • Select Send.

That completes the round trip. You should see hello appear beneath the form.

Message not appearing?

Refresh the page after restarting the server so the browser creates a fresh connection. Check that the message handler uses the same socket received by the connection handler.

Help me debug the one-tab WebSocket echo.

Expose the second-tab gap

One successful echo proves that a browser and server can exchange data over one persistent connection. A second tab creates another connection and tests whether the current message path reaches it.

  • Keep the working TabTalk tab open.
  • Open a second browser tab.
  • Enter http://localhost:8080 in the second tab's address bar.

Before you send another message, do you think both tabs will display it?

  • Return to the first TabTalk tab.
  • Enter hello in the message field.
  • Select Send.
  • Switch to the second TabTalk tab.

The first tab displays hello. The second tab remains empty.

This is the intended shortfall. The server echoes each frame through the sender's socket, so the other connection never receives it.

You now have a complete browser-to-server echo loop and visible proof of its limitation. Next, you will turn these isolated connections into one shared real-time chat.

Broadcast Messages Between Tabs

Your first round trip proved that each browser tab can reach the Node.js WebSocket server. It also exposed the gap: the server sends each frame back only to its original socket.

The server already tracks every connection through wss.clients. This step uses that collection to forward each message to every open tab.

In this step, get ready to:
  • Replace the sender-only echo with a loop over the server's tracked clients.
  • Protect each send with a connection readiness check while preserving the incoming frame type.
  • Prove that messages travel in both directions between two browser tabs.
Broadcast to tracked clients

The current message handler knows about the socket that sent the frame. Broadcasting starts by switching from that single socket to the full set of connected clients.

  • In the Explorer sidebar in Visual Studio Code, select server.mjs.
  • Find the sender-only line inside the message listener:
socket.send(data, { binary: isBinary });

Why does this isolate the tabs?

The socket variable represents only the connection that delivered the message. Calling socket.send() returns the frame to that sender.

  • Replace the sender-only line with this tracked-client loop:
wss.clients.forEach((client) => {
  if (client.readyState === WebSocket.OPEN) {
    client.send(data, { binary: isBinary });
  }
});

What does this broadcast loop do?

  • The wss.clients collection contains the connections currently tracked by the server.
  • The client.readyState === WebSocket.OPEN check limits sending to connections that are ready to communicate.
  • The client.send() call forwards the incoming data to each open connection.
  • The isBinary value preserves whether the incoming frame contains text or binary data.
  • Save server.mjs.

✔️ Awesome, I've got everything!

Your saved message handler now broadcasts each incoming frame to every open client.

ⓧ I'd like to double check the full code

import { readFileSync } from 'node:fs';
import * as http from 'node:http';
import WebSocket, { WebSocketServer } from 'ws';

const html = readFileSync('./index.html');

const server = http.createServer((request, response) => {
  if (request.url !== '/') {
    response.writeHead(404, { 'Content-Type': 'text/plain' });
    response.end('Not found');
    return;
  }

  response.writeHead(200, { 'Content-Type': 'text/html' });
  response.end(html);
});

const wss = new WebSocketServer({ server });

wss.on('connection', (socket) => {
  socket.on('error', console.error);

  socket.on('message', (data, isBinary) => {
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(data, { binary: isBinary });
      }
    });
  });
});

server.listen(8080, () => {
  console.log('TabTalk is running at http://localhost:8080');
});

How to use this reference

Compare this reference with your saved server.mjs. The broadcast loop should sit inside the existing message listener.

Restart the broadcast server

The running Node.js process still holds the echo version in memory. Restarting the process loads the broadcast loop from your saved file.

  • In the existing Visual Studio Code PowerShell terminal, press Ctrl+C to stop the running server.
  • Pause to predict what the terminal should confirm when the updated file loads.
  • Restart the server by running this command:
node server.mjs

What should you see?

The terminal should print TabTalk is running at http://localhost:8080. This confirms that the updated server started successfully.

  • Refresh the first TabTalk tab in your browser.
  • Refresh the second TabTalk tab in your browser.

You should see a fresh TabTalk page in each tab. Each refreshed page now has a connection in the server's tracked client set.

Server not restarting?

  • Stop the previous server process before starting another one.
  • Check that every opening brace in the new loop has a matching closing brace.
  • Confirm that the broadcast loop remains inside the existing message listener.

Ask for help with the restart output: Help me troubleshoot why my TabTalk server does not restart after replacing the echo with the wss.clients broadcast loop.

Verify cross-tab delivery

Each refreshed tab now owns a separate connection in wss.clients. Sending from both directions proves that the server treats those connections as one shared channel.

  • Pause to predict whether one message from the first tab reaches one tab or both tabs.
  • Enter hello in the Write a message field in the first tab.
  • Select Send in the first tab.

You should see hello appear immediately in both tabs.

  • Enter reply from tab two in the Write a message field in the second tab.
  • Select Send in the second tab.

You should see reply from tab two appear in both tabs. The same server path now carries messages in either direction.

Does one tab still stay empty?

  • Refresh both tabs after restarting the server.
  • Confirm that wss.clients.forEach replaced the original socket.send line.
  • Check that the readiness condition uses client.readyState === WebSocket.OPEN.

Get help tracing the broadcast path: Help me debug why my TabTalk message appears in only one browser tab.

That is the isolation problem solved. Next, you'll add usernames plus visible connection states to make the shared channel usable.

Add Identity and Lifecycle Feedback

Your two tabs now share every message through one persistent WebSocket broadcast. Each line still arrives without a sender.

A silent disconnect creates another problem. The final client needs a structured JSON message envelope plus visible lifecycle feedback so people know when sending is safe.

In this step, get ready to:
  • Expand the interface with a username field and a connection badge.
  • Exchange named chat messages through structured JSON envelopes.
  • Handle each connection state while preventing sends through a closed socket.
Build the chat interface

The client needs controls that reflect whether its socket is ready. A disabled send button blocks premature messages while the status badge shows what the connection is doing.

  • In the index.html tab from earlier, find the opening <body> tag.
  • Select everything from that tag through the closing </form> line.
  • Replace the selection by pasting the interface below:
<body>
  <main class="app">
    <header>
      <h1>TabTalk</h1>
      <span id="status">Connecting</span>
    </header>
    <ul id="messages" aria-live="polite"></ul>
    <form id="chat-form" class="controls">
      <input id="username" autocomplete="name" placeholder="Your name" required>
      <input id="message" autocomplete="off" placeholder="Write a message" required>
      <button id="send" type="submit" disabled>Send</button>
    </form>
  </main>

What does this interface add?

  • The status element gives each tab a visible connection state.
  • The username input gives every outgoing message an identity.
  • The disabled send button waits for the socket to open.
  • The aria-live attribute lets assistive technology announce new messages.
  • Save index.html.
  • Refresh the first TabTalk tab.
  • Refresh the second TabTalk tab.

You will see a username field beside the message field. Each tab also shows Connecting while its Send button remains disabled.

Missing the new controls?

Check that the new markup sits inside <body> before the existing <script> block. Confirm that you saved index.html before refreshing both tabs.

Help me check why the updated TabTalk interface is not appearing.

Wire the JSON message flow

The script needs references to the new controls before it can update them. Small helper functions keep status changes separate from safe message rendering.

  • Find the constants block that starts with const form.
  • Select through the line that creates socket.
  • Replace that block with the client references and helpers below:
    const form = document.querySelector('#chat-form');
    const usernameInput = document.querySelector('#username');
    const messageInput = document.querySelector('#message');
    const sendButton = document.querySelector('#send');
    const messages = document.querySelector('#messages');
    const status = document.querySelector('#status');
    const socket = new WebSocket(`ws://${window.location.host}`);

    function setStatus(text, className = '') {
      status.textContent = text;
      status.className = className;
    }

    function appendMessage(message, className = '') {
      const item = document.createElement('li');
      item.className = className;
      item.textContent = message;
      messages.append(item);
      messages.scrollTop = messages.scrollHeight;
    }

    socket.addEventListener('open', () => {
      setStatus('Connected', 'connected');
      sendButton.disabled = false;
    });

How does the client become ready?

  • The new constants connect the script to the username field and status controls.
  • The setStatus() helper updates the badge text plus its state class.
  • The appendMessage() helper uses textContent so received text stays text inside the page.
  • The open event enables sending only after the connection is ready.
  • Save index.html.
  • Refresh the first TabTalk tab.
  • Refresh the second TabTalk tab.

Each badge changes to Connected. Each Send button becomes available.

Still stuck on Connecting?

Check that the open listener appears after the socket constant. Confirm that the server process from earlier is still running.

Help me debug why TabTalk stays on Connecting after adding the open listener.

The sender and receiver must now agree on one message shape. The browser sends a chat envelope containing type, user, and text.

  • Find the existing message listener below the new open listener.
  • Select through the closing }); directly after messageInput.value = '';.
  • Replace that selection with the message and form handlers below:
    socket.addEventListener('message', (event) => {
      try {
        const message = JSON.parse(event.data);
        if (message.type === 'chat') {
          appendMessage(`${message.user}: ${message.text}`);
        }
      } catch {
        appendMessage('Received an invalid message.', 'system');
      }
    });

    socket.addEventListener('error', () => {
      setStatus('Connection error', 'error');
    });

    socket.addEventListener('close', () => {
      setStatus('Disconnected', 'error');
      sendButton.disabled = true;
    });

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      const user = usernameInput.value.trim();
      const text = messageInput.value.trim();
      if (!user || !text || socket.readyState !== WebSocket.OPEN) return;
      socket.send(JSON.stringify({ type: 'chat', user, text }));
      messageInput.value = '';
      messageInput.focus();
    });

How do named messages stay safe?

  • The submit handler trims both values before building the chat envelope.
  • The WebSocket.OPEN check blocks sends when the socket cannot communicate.
  • The message listener parses each envelope before rendering the sender beside the text.
  • The catch block turns malformed input into a visible system message.

Before you send the next message, which username do you expect both tabs to display beside it?

  • Save index.html.
  • Refresh the first TabTalk tab.
  • Refresh the second TabTalk tab.
  • Enter Ada in the first tab's username field.
  • Enter Lin in the second tab's username field.
  • Type hello in the first tab's message field.
  • Select Send in the first tab.
  • Send hi back from the second tab.

Both tabs show Ada: hello followed by Lin: hi back. The sender identity now travels with every broadcast message.

Seeing an invalid message?

Confirm that the submit handler wraps the envelope with JSON.stringify(). Check that the message listener reads the same envelope with JSON.parse().

Help me debug why TabTalk shows an invalid message after I added JSON chat envelopes.

Verify every lifecycle state

A tab should release its connection when the page leaves active use. The server can then stop tracking a socket that the browser no longer needs.

  • Find the closing }); for the submit listener near the bottom of index.html.
  • Add the cleanup listener directly below it by pasting this code:
    window.addEventListener('pagehide', () => {
      socket.close();
    });

Why close on pagehide?

The pagehide event closes the connection when the browser leaves the page. This keeps the socket lifecycle aligned with the tab lifecycle.

  • Save index.html.
  • Refresh the first TabTalk tab.
  • Refresh the second TabTalk tab.

Before you stop the server, what do you expect each status badge and Send button to do?

  • Return to the Visual Studio Code PowerShell terminal from earlier.
  • Press Ctrl+C to stop the running server.
  • Return to the first TabTalk tab.
  • Return to the second TabTalk tab.

The status changes to Disconnected in both tabs. Each Send button becomes disabled.

That closes the loop. Your client exposes the disconnect immediately instead of accepting a message that cannot be sent.

The lifecycle check deliberately stopped your local server. Restore it now so TabTalk finishes this step online.

  • Return to the PowerShell terminal.
  • Press the Up Arrow key to recall the previous server command.
  • Press Enter to restart the server.
  • Refresh the first TabTalk tab.
  • Refresh the second TabTalk tab.

Both tabs return to Connected. Their Send buttons become available again.

Status stays Disconnected?

Confirm that the terminal shows the running TabTalk server again. Refresh both browser tabs only after the server has restarted.

Help me restore TabTalk after testing the WebSocket close lifecycle.

Your complete index.html should now match the final client below. The existing package.json and server.mjs remain unchanged.

✔️ Awesome, I've got everything!

Great work. Your two-tab chat now carries sender identity and reports every important connection state.

ⓧ 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>TabTalk</title>
</head>
<body>
  <main class="app">
    <header>
      <h1>TabTalk</h1>
      <span id="status">Connecting</span>
    </header>
    <ul id="messages" aria-live="polite"></ul>
    <form id="chat-form" class="controls">
      <input id="username" autocomplete="name" placeholder="Your name" required>
      <input id="message" autocomplete="off" placeholder="Write a message" required>
      <button id="send" type="submit" disabled>Send</button>
    </form>
  </main>
  <script>
    const form = document.querySelector('#chat-form');
    const usernameInput = document.querySelector('#username');
    const messageInput = document.querySelector('#message');
    const sendButton = document.querySelector('#send');
    const messages = document.querySelector('#messages');
    const status = document.querySelector('#status');
    const socket = new WebSocket(`ws://${window.location.host}`);

    function setStatus(text, className = '') {
      status.textContent = text;
      status.className = className;
    }

    function appendMessage(message, className = '') {
      const item = document.createElement('li');
      item.className = className;
      item.textContent = message;
      messages.append(item);
      messages.scrollTop = messages.scrollHeight;
    }

    socket.addEventListener('open', () => {
      setStatus('Connected', 'connected');
      sendButton.disabled = false;
    });

    socket.addEventListener('message', (event) => {
      try {
        const message = JSON.parse(event.data);
        if (message.type === 'chat') {
          appendMessage(`${message.user}: ${message.text}`);
        }
      } catch {
        appendMessage('Received an invalid message.', 'system');
      }
    });

    socket.addEventListener('error', () => {
      setStatus('Connection error', 'error');
    });

    socket.addEventListener('close', () => {
      setStatus('Disconnected', 'error');
      sendButton.disabled = true;
    });

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      const user = usernameInput.value.trim();
      const text = messageInput.value.trim();
      if (!user || !text || socket.readyState !== WebSocket.OPEN) return;
      socket.send(JSON.stringify({ type: 'chat', user, text }));
      messageInput.value = '';
      messageInput.focus();
    });

    window.addEventListener('pagehide', () => {
      socket.close();
    });
  </script>
</body>
</html>

Secret mission

Show Live Participant Count

Your server already tracks every connected browser tab. Turn that hidden connection set into a live counter that moves as participants join or leave while chat messages keep flowing.

Clean Up Your Resources

Clean Up Your Resources

TabTalk keeps every project resource on your computer. The local setup has no ongoing cost.

Resources you used:

  • The running local Node.js server process in the Visual Studio Code PowerShell terminal.
  • The local tabtalk folder containing every project file.

Keep everything running

No action is needed. TabTalk stays available for more two-tab tests.

  • The local server remains available at http://localhost:8080 while its terminal process keeps running.
  • The tabtalk folder preserves the complete project.
  • This local setup continues to cost nothing.

Pause - I'll come back to this later

Stopping the local server releases its port. Every project file remains in tabtalk.

  • Return to the Visual Studio Code PowerShell terminal from earlier.
  • Press Ctrl+C to stop the Node.js server.

Each open chat tab shows Disconnected. The Send button becomes disabled.

  • Close both browser tabs.
  • Keep the tabtalk folder in place for your next session.

Delete - I don't want to use this again

Removing the local tabtalk folder gives you a clean slate without affecting any cloud account. Deletion becomes permanent only when you remove the folder from the Recycle Bin.

  • Return to the Visual Studio Code PowerShell terminal from earlier.
  • Press Ctrl+C to stop the Node.js server.

Each open chat tab shows Disconnected. The server has released its local connection.

  • Close Visual Studio Code.
  • Close both TabTalk browser tabs.
  • Open File Explorer from the Windows taskbar.
  • Return to the location that contains the tabtalk folder.
  • Move the tabtalk folder to the Recycle Bin.
  • Open the Recycle Bin from the Windows desktop.
  • Permanently delete the tabtalk folder from the Recycle Bin.
  • Use Windows search to look for tabtalk.

You should no longer find the project folder. That clears TabTalk from your computer.

Nice Work!

Nice Work!

You did it! Your TabTalk chat now carries named messages between browser tabs through a local Node.js WebSocket server.

What you learned:

  • Built a local server architecture that serves index.html over HTTP. The same server accepts persistent WebSocket connections from every browser tab.
  • Created a native browser client around the open, message, error, and close lifecycle. Used structured JSON envelopes to carry each message type. Each envelope also carries a username. Each envelope carries the message text. Rendered received text safely through textContent.
  • Experienced the limitation of sender-only echoing when a second tab stayed empty. Replaced that echo with broadcast delivery across tracked clients. Guarded every delivery with WebSocket.OPEN.
  • Secret Mission: Extended the protocol with a live participant counter that updates whenever tabs connect or close.

Ready to quiz yourself?