Build a WebSocket Status Dashboard
Build a local dashboard that streams service status updates with WebSockets.
Introduction
30 Second Summary
A status page can look healthy while a service is failing behind it. Without live updates, that screen becomes misleading within seconds.
In this project, you will build Service Pulse, a local operations dashboard powered by Node.js and WebSockets. It displays live health updates for an API, a database, and a background worker without polling or page refreshes.
What You'll Build
You'll open Service Pulse in two Microsoft Edge tabs to watch matching service cards change the moment your local server broadcasts an update.
By the end of this project, you'll have:
- Watch three service cards display live service health with changing latency values.
- Keep multiple browser tabs synchronized through one shared WebSocket channel.
- See an honest connection state when the server stops. Restart the server to watch automatic reconnection restore the live updates.
- Secret Mission: add a bounded incident timeline that keeps the six newest degraded or outage events.
Are there any prerequisites?
You should be comfortable writing JavaScript with Node.js. The guide covers project setup plus dependency installation.
Before We Start
Before the hands-on work begins, define what Service Pulse needs to show and why instant updates matter. This gives every later decision a clear purpose.
Set Up the Local Runtime
Service Pulse needs a reliable local foundation before a WebSocket can carry live status updates. A known Node.js runtime makes page delivery predictable.
You will verify that runtime inside Visual Studio Code. You will then create the smallest local server that can deliver the Service Pulse page.
In this step, get ready to:
- Verify the Node.js reference environment through PowerShell.
- Create the starter project files with the exact server dependency.
- Launch the starter page in Microsoft Edge.
Verify Node.js and npm
The runtime check confirms whether your computer can execute this project. The reference environment uses Node.js v24.21.0 LTS.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Click View in the top menu.
- Select Terminal.
- Open the terminal profile dropdown beside the plus button.
- Select PowerShell.
- Check the installed Node.js version and npm version by running:
node --version
npm --version
What do these commands check?
- The first command prints the installed Node.js version.
- The second command prints the installed npm version.
- Together they confirm that the runtime can execute the project scripts.
✔️ I see the reference version or higher
The first line should report v24.21.0 or a later supported version. The second line should contain an npm version number.
Your local JavaScript runtime is ready for Service Pulse.
ⓧ I see an older Node.js version
An older runtime can behave differently from the reference environment. Install Node.js v24.21.0 LTS before creating the project.
- Download the Node.js v24.21.0 LTS Windows installer from the official Node.js download page.
- Run the downloaded Windows installer.
- Complete the installation using its default options.
- Close Visual Studio Code after the installation finishes.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into the search field.
- Press Enter to reopen Visual Studio Code.
- Repeat the two version checks above.
The first check should now report v24.21.0 or a later supported version.
ⓧ The Node.js command is missing
Your terminal cannot reach a Node.js installation. The official Windows installer adds the runtime needed for this project.
- Download the Node.js v24.21.0 LTS Windows installer from the official Node.js download page.
- Run the downloaded Windows installer.
- Complete the installation using its default options.
- Close Visual Studio Code after the installation finishes.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into the search field.
- Press Enter to reopen Visual Studio Code.
- Repeat the two version checks above.
Both commands should now print version numbers.
Runtime check still failing?
- Restart Visual Studio Code after installing Node.js so the terminal receives the updated system path.
- Confirm that your terminal profile is PowerShell before repeating the checks.
- Ask for help with the exact output you see: Help me troubleshoot my Node.js and npm version checks in the Visual Studio Code PowerShell terminal.
Create the starter dashboard files
An HTTP server needs a browser page to return when someone visits its local address. Keeping the page inside public/index.html separates browser content from server code.
- Click File in the Visual Studio Code menu.
- Select Open Folder....
- Select your Desktop in the folder dialog.
- Select New Folder.
- Enter realtime-status-dashboard as the folder name.
- Select Select Folder.
- Confirm that you trust the folder if a workspace trust prompt appears.
Your Explorer now shows realtime-status-dashboard as the open workspace.
- Select the New Folder button in the Explorer.
- Type public into the folder name field.
- Press Enter.
- Select the public folder in the Explorer.
- Select the New File... button.
- Type index.html into the file name field.
- Press Enter.
- Build the starter Service Pulse page by replacing the blank file with:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Service Pulse</title>
</head>
<body>
<h1>Service Pulse</h1>
</body>
</html>
What does this page contain?
- The document metadata gives the browser a page title plus responsive viewport settings.
- The body contains the visible Service Pulse heading.
- This small page gives the HTTP server a concrete response to deliver.
- Save public/index.html.
- Confirm that the Explorer shows index.html inside the public folder.
Page file missing?
- Expand the public folder in the Explorer to reveal its contents.
- Check that the filename ends with .html.
- Ask for help with the Explorer layout: Help me place index.html inside the public folder in Visual Studio Code.
The browser page now exists. The server needs to read that file before it can deliver it through the local URL.
- Select the realtime-status-dashboard folder in the Explorer.
- Select the New File... button.
- Type server.js into the file name field.
- Press Enter.
- Create the local HTTP server by replacing the blank file with:
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
const HOST = "127.0.0.1";
const PORT = 8080;
const indexUrl = new URL("./public/index.html", import.meta.url);
const server = createServer(async (request, response) => {
if (request.url !== "/" && request.url !== "/index.html") {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
return;
}
try {
const html = await readFile(indexUrl);
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(html);
} catch (error) {
console.error(error);
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Unable to load the dashboard");
}
});
server.listen(PORT, HOST, () => {
console.log(`Dashboard running at http://${HOST}:${PORT}`);
});
What does this server do?
- The imports provide Node.js tools for creating an HTTP server.
- The file reader loads public/index.html relative to server.js.
- The request handler serves the page for / or /index.html.
- The listener binds the server to 127.0.0.1 on port 8080.
- Save server.js.
- Confirm that the Explorer lists server.js beside the public folder.
Server file in the wrong place?
- Move server.js beside the public folder if it appears inside that folder.
- Check that the file uses the exact lowercase name server.js.
- Ask for help with the file layout: Help me check the Service Pulse project file structure.
Install ws and launch the server
A package manifest is a JSON document that tells npm how to run the project. It also pins the ws library to one exact release.
- Select the realtime-status-dashboard folder in the Explorer.
- Select the New File... button.
- Type package.json into the file name field.
- Press Enter.
- Define the project scripts plus dependency by replacing the blank file with:
{
"name": "realtime-status-dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ws": "8.21.3"
}
}
What does this manifest define?
- The type field enables ES module imports in server.js.
- The start script runs the local server file.
- The dependency entry pins ws to version 8.21.3.
- The private setting protects this local project from accidental publication.
- Save package.json.
- Confirm that the Explorer lists package.json beside server.js.
Seeing JSON problems?
- Check that every property name uses double quotes.
- Confirm that the final dependency entry has no trailing comma.
- Ask for help with the manifest: Help me find the JSON syntax problem in package.json.
npm reads the manifest to install local dependencies. The installation also records the resolved package information in a lockfile.
- Switch back to the PowerShell terminal from earlier.
- Install the declared project dependency by running:
npm install
What does this command create?
- npm reads the dependency declaration from package.json.
- The node_modules directory receives the local ws package.
- The generated package-lock.json records the resolved dependency tree.
- Wait for the installation command to finish.
- Confirm that the Explorer shows package-lock.json.
- Confirm that the Explorer shows node_modules/ws.
The exact ws version 8.21.3 is now installed inside your project.
Dependency installation failing?
- Confirm that the terminal path ends with realtime-status-dashboard.
- Check that package.json is saved before repeating the installation.
- Ask for help with the terminal output: Help me troubleshoot npm install for the Service Pulse project.
The start script keeps the PowerShell terminal busy while the server is running. You can stop the process later by pressing Ctrl+C.
Before you start the server, do you expect the browser to receive the page from the local address?
- Start the local dashboard server by running:
npm start
What does the start command run?
npm reads the start script from package.json. That script launches server.js with Node.js.
You will see Dashboard running at http://127.0.0.1:8080 in the terminal.
- Press the Windows key to open Windows Search.
- Type Microsoft Edge into the search field.
- Press Enter to open Microsoft Edge.
- Enter http://127.0.0.1:8080 into the address bar.
- Press Enter to load the local page.
You will see the Service Pulse heading on a plain page. The address bar will show http://127.0.0.1:8080.
Local page not loading?
- Keep the PowerShell terminal running while Microsoft Edge loads the page.
- Check that the terminal shows Dashboard running at http://127.0.0.1:8080.
- Confirm that the address uses port 8080.
- Ask for help with the server output: Help me troubleshoot why my local Service Pulse page is not loading.
That is your first working slice. Your Node.js server now delivers Service Pulse to Microsoft Edge.
✔️ Awesome, I've got everything!
Great. Keep the PowerShell terminal running so the local dashboard stays available.
ⓧ I'd like to double check the full code
Compare package.json
{
"name": "realtime-status-dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ws": "8.21.3"
}
}
What should match?
Compare every property with your saved package.json. The dependency version must remain 8.21.3.
Compare server.js
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
const HOST = "127.0.0.1";
const PORT = 8080;
const indexUrl = new URL("./public/index.html", import.meta.url);
const server = createServer(async (request, response) => {
if (request.url !== "/" && request.url !== "/index.html") {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
return;
}
try {
const html = await readFile(indexUrl);
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(html);
} catch (error) {
console.error(error);
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Unable to load the dashboard");
}
});
server.listen(PORT, HOST, () => {
console.log(`Dashboard running at http://${HOST}:${PORT}`);
});
What should match?
Compare the imports plus request handler with your saved server.js. Confirm that HOST is 127.0.0.1.
Confirm that PORT is 8080.
Compare public/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Service Pulse</title>
</head>
<body>
<h1>Service Pulse</h1>
</body>
</html>
What should match?
Compare the page title plus visible heading with your saved public/index.html.
Confirm generated artifacts
- Confirm that package-lock.json was generated for the realtime-status-dashboard package.
- Confirm that the lockfile records ws version 8.21.3.
- Confirm that node_modules/ws contains the installed local package.
Your local runtime can now serve the dashboard on demand. Next up, you will turn the plain page into a recognizable service-status snapshot.
Build the Snapshot Dashboard
Your local Node.js server already delivers Service Pulse to Microsoft Edge. The page proves the local route works.
A useful status view must make service health clear at a glance. In this step, you will build that visual snapshot and test whether loading the same page twice keeps it current.
In this step, get ready to:
- Build the dashboard's static layout.
- Style three service snapshot cards.
- Test whether two browser tabs stay current.
Build the dashboard layout
The dashboard needs a clear visual hierarchy before it displays service details. The header establishes the page purpose while the summary and grid create dedicated spaces for status information.
- Switch back to Visual Studio Code.
- Select public/index.html in the Explorer sidebar.
- Find the existing <body> section.
- Replace the existing body with this dashboard structure:
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Part 1: WebSocket Project</p>
<h1>Service Pulse</h1>
</div>
<div id="connectionPill" class="connection" data-state="connecting">
<span class="dot" aria-hidden="true"></span>
<span id="connectionText">Connecting</span>
</div>
</header>
<section class="summary" aria-label="Dashboard summary">
<span>Three simulated services</span>
<span id="lastSync">Waiting for first snapshot</span>
</section>
<section id="serviceGrid" class="grid" aria-live="polite">
<p>Waiting for service data...</p>
</section>
</main>
</body>
What does this structure do?
- The topbar groups the dashboard title with its connection pill.
- The summary creates a compact place for service count and synchronization information.
- The serviceGrid marks the area where the three service cards will appear.
- Save public/index.html.
- Return to the Microsoft Edge tab from the previous step.
- Refresh the dashboard page.
Good progress. You should now see the Service Pulse header, a Connecting label, a summary row, and a waiting message.
Still seeing only the old title?
Confirm that you edited public/index.html inside the realtime-status-dashboard folder. Save the file before refreshing Microsoft Edge.
If the old page remains, use Help me find why my updated public/index.html is not appearing in Microsoft Edge.
The structure now needs a visual system that separates the page from its status panels. Start with the color palette and outer page dimensions.
- Find the <title>Service Pulse</title> line in public/index.html.
- Paste this style block directly below it:
<style>
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #07111f;
color: #e8eef7;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #153258, #07111f 55%);
}
.shell {
width: min(1050px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0;
}
</style>
What does this styling do?
- The :root rules establish the dark color palette and shared font stack.
- The body fills the browser height with a dark radial gradient.
- The .shell keeps the dashboard centered with space around its content.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
You should see the content centered on a dark blue background with space above and below it.
Background still white?
Check that the <style> block sits inside <head>. Confirm that its closing tag is </style>.
Use Help me debug why the dark CSS in public/index.html is not appearing.
The main panels need shared surfaces so they read as parts of one dashboard. The header also needs spacing that keeps its title separate from the connection pill.
- Insert this CSS immediately before the existing </style> tag:
.topbar,
.summary,
.card {
background: rgba(12, 28, 49, 0.86);
border: 1px solid #294563;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}
.topbar {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
padding: 28px;
border-radius: 22px;
}
.eyebrow {
margin: 0 0 8px;
color: #77b7ff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(2rem, 5vw, 3.8rem); }
How does the header take shape?
- The shared surface rules give the header, summary, and future cards the same border and translucent background.
- The .topbar rules separate the title area from the connection pill.
- The .eyebrow and h1 rules establish the dashboard's visual hierarchy.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
You should see the title inside a rounded panel. The smaller project label should appear in blue above Service Pulse.
Header styles missing?
Confirm that this CSS appears before the existing </style> tag. A rule placed after that tag will appear as page text instead of styling.
Use Help me check the topbar and eyebrow CSS in my Service Pulse page.
The connection pill needs a compact shape that remains readable beside the title. Its dot gives the state a visual signal alongside the text label.
- Insert this CSS immediately before the existing </style> tag:
.connection {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
border-radius: 999px;
background: #253650;
font-weight: 750;
white-space: nowrap;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #94a3b8;
}
.connection[data-state="connected"] .dot { background: #37d67a; }
.connection[data-state="error"] .dot { background: #ff6577; }
.connection[data-state="reconnecting"] .dot { background: #f6c85f; }
How does the pill show state?
- The .connection rules create the rounded pill around the state label.
- The .dot rules create the small circular state indicator.
- The data-state selectors prepare distinct colors for connected, error, and reconnecting states.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
You should see Connecting inside a rounded dark pill with a gray dot.
Pill still looks like plain text?
Check that the header uses class="connection". Confirm that the dot uses class="dot".
Use Help me find why my connection pill CSS does not match its HTML classes.
The remaining layout rules define a compact summary panel and a three-column card grid. Status-specific borders prepare each card to communicate changing health through color.
- Insert this CSS immediately before the existing </style> tag:
.summary {
display: flex;
justify-content: space-between;
gap: 16px;
margin: 22px 0;
padding: 16px 20px;
border-radius: 16px;
color: #b8c7da;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
.card {
padding: 22px;
border-radius: 18px;
border-top: 4px solid #37d67a;
}
.card[data-status="degraded"] { border-top-color: #f6c85f; }
.card[data-status="outage"] { border-top-color: #ff6577; }
How does the grid work?
- The .summary rules keep its two pieces of context separated inside one panel.
- The .grid rules reserve three equal columns for the service cards.
- Each .card receives a green top border by default. The status selectors provide yellow and red alternatives.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
You should see the summary inside its own rounded panel. The waiting message remains below it in the reserved grid area.
Summary panel not separated?
Confirm that the shared selector near the top of your styles includes .summary. Check that the HTML section uses the same class.
Use Help me debug the summary and grid layout in public/index.html.
Add the service snapshot cards
Each card captures one service at a particular moment. A name, health badge, latency value, and update time make that moment concrete.
- Find the paragraph containing Waiting for service data... inside serviceGrid.
- Replace that paragraph with these three cards:
<article class="card" data-status="operational">
<div class="status-row">
<h2>Public API</h2>
<span class="badge">Operational</span>
</div>
<p class="latency">42 ms</p>
<p class="updated">Updated 10:00:00 AM</p>
</article>
<article class="card" data-status="operational">
<div class="status-row">
<h2>Primary Database</h2>
<span class="badge">Operational</span>
</div>
<p class="latency">28 ms</p>
<p class="updated">Updated 10:00:00 AM</p>
</article>
<article class="card" data-status="operational">
<div class="status-row">
<h2>Background Worker</h2>
<span class="badge">Operational</span>
</div>
<p class="latency">64 ms</p>
<p class="updated">Updated 10:00:00 AM</p>
</article>
What does each card show?
- The data-status="operational" value selects the card's green health color.
- The badge presents the health label beside the service name.
- The latency and update time capture the values shown by this particular snapshot.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
You should see Public API, Primary Database, and Background Worker in three separate cards. Their latency values should be 42 ms, 28 ms, and 64 ms.
Cards missing or stacked incorrectly?
Confirm that all three <article> elements sit inside the section with id="serviceGrid". Check that each card has its own closing tag.
Use Help me check the three service card elements in public/index.html.
The cards now need internal alignment so the service name and badge share one row. The badge colors make operational, degraded, and outage states distinguishable.
- Insert this CSS immediately before the existing </style> tag:
.status-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.card h2 { margin: 0; font-size: 1.15rem; }
.badge {
padding: 6px 9px;
border-radius: 999px;
background: #183c2b;
color: #8cf0b5;
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.card[data-status="degraded"] .badge {
background: #473b19;
color: #ffe18b;
}
.card[data-status="outage"] .badge {
background: #4c2029;
color: #ff9eaa;
}
How do the badges communicate health?
- The .status-row rules keep each service name aligned with its badge.
- The base badge uses green text and a dark green background for operational health.
- The status selectors define yellow styling for degraded health and red styling for outages.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
You should see each Operational badge aligned to the right of its service name inside a green pill.
Badges not aligned?
Check that each service name and badge are both inside <div class="status-row">. Confirm that every badge uses class="badge".
Use Help me debug the status row and badge alignment in my service cards.
Latency is the card's primary measurement, so it needs stronger visual weight. A responsive rule also keeps the dashboard readable when the browser window becomes narrow.
- Insert this final CSS immediately before the existing </style> tag:
.latency {
margin: 24px 0 4px;
font-size: 2rem;
font-weight: 850;
}
.updated { margin: 0; color: #8da2ba; font-size: 0.86rem; }
@media (max-width: 760px) {
.topbar,
.summary { align-items: flex-start; flex-direction: column; }
.grid { grid-template-columns: 1fr; }
}
How does the dashboard stay readable?
- The .latency rules make response time the most prominent value on each card.
- The .updated rules keep the timestamp visible without competing with the latency.
- The media query changes the grid to one column when the page width reaches 760 pixels or less.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
- Narrow the Microsoft Edge window to less than 760 pixels wide.
You should see the cards move into one column. Each latency value should remain large while its timestamp stays underneath.
Grid stays in three columns?
Confirm that the media query sits inside the <style> block. Check that the service container still uses class="grid".
Use Help me debug why the Service Pulse grid does not switch to one column.
✔️ Awesome, I've got everything!
Great. Double-check that you saved public/index.html.
ⓧ I'd like to double check the full code
Compare your complete public/index.html file with this static snapshot version.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Service Pulse</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #07111f;
color: #e8eef7;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #153258, #07111f 55%);
}
.shell {
width: min(1050px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0;
}
.topbar,
.summary,
.card {
background: rgba(12, 28, 49, 0.86);
border: 1px solid #294563;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}
.topbar {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
padding: 28px;
border-radius: 22px;
}
.eyebrow {
margin: 0 0 8px;
color: #77b7ff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(2rem, 5vw, 3.8rem); }
.connection {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
border-radius: 999px;
background: #253650;
font-weight: 750;
white-space: nowrap;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #94a3b8;
}
.connection[data-state="connected"] .dot { background: #37d67a; }
.connection[data-state="error"] .dot { background: #ff6577; }
.connection[data-state="reconnecting"] .dot { background: #f6c85f; }
.summary {
display: flex;
justify-content: space-between;
gap: 16px;
margin: 22px 0;
padding: 16px 20px;
border-radius: 16px;
color: #b8c7da;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
.card {
padding: 22px;
border-radius: 18px;
border-top: 4px solid #37d67a;
}
.card[data-status="degraded"] { border-top-color: #f6c85f; }
.card[data-status="outage"] { border-top-color: #ff6577; }
.status-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.card h2 { margin: 0; font-size: 1.15rem; }
.badge {
padding: 6px 9px;
border-radius: 999px;
background: #183c2b;
color: #8cf0b5;
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.card[data-status="degraded"] .badge {
background: #473b19;
color: #ffe18b;
}
.card[data-status="outage"] .badge {
background: #4c2029;
color: #ff9eaa;
}
.latency {
margin: 24px 0 4px;
font-size: 2rem;
font-weight: 850;
}
.updated { margin: 0; color: #8da2ba; font-size: 0.86rem; }
@media (max-width: 760px) {
.topbar,
.summary { align-items: flex-start; flex-direction: column; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Part 1: WebSocket Project</p>
<h1>Service Pulse</h1>
</div>
<div id="connectionPill" class="connection" data-state="connecting">
<span class="dot" aria-hidden="true"></span>
<span id="connectionText">Connecting</span>
</div>
</header>
<section class="summary" aria-label="Dashboard summary">
<span>Three simulated services</span>
<span id="lastSync">Waiting for first snapshot</span>
</section>
<section id="serviceGrid" class="grid" aria-live="polite">
<article class="card" data-status="operational">
<div class="status-row">
<h2>Public API</h2>
<span class="badge">Operational</span>
</div>
<p class="latency">42 ms</p>
<p class="updated">Updated 10:00:00 AM</p>
</article>
<article class="card" data-status="operational">
<div class="status-row">
<h2>Primary Database</h2>
<span class="badge">Operational</span>
</div>
<p class="latency">28 ms</p>
<p class="updated">Updated 10:00:00 AM</p>
</article>
<article class="card" data-status="operational">
<div class="status-row">
<h2>Background Worker</h2>
<span class="badge">Operational</span>
</div>
<p class="latency">64 ms</p>
<p class="updated">Updated 10:00:00 AM</p>
</article>
</section>
</main>
</body>
</html>
Test the snapshot in two tabs
A status dashboard earns trust only when its information stays current. Two tabs provide a simple test of whether this page can receive new information after it finishes loading.
- Return to the first Microsoft Edge dashboard tab.
- Refresh the first tab.
- Open a second Microsoft Edge tab.
- Enter http://127.0.0.1:8080 in the second tab's address bar.
Both tabs should show the same three operational services with matching latency values and timestamps.
Before you wait, do you think either tab can change after its page finishes loading?
- Wait 10 seconds without refreshing either tab.
- Compare the service badges, latency values, and timestamps across both tabs.
Neither tab changes. Both pages remain frozen on the same values they received when the HTML loaded.
Why does the snapshot become stale?
The browser received a fixed set of service cards from the local page. No communication channel delivers later changes to either tab.
This is the intended limitation of the snapshot. A convincing status page can still hide a new slowdown or outage when its values never move.
You found the gap that matters: the dashboard looks complete while its data remains frozen. Next, you will connect it to a WebSocket channel so both tabs receive the same live service changes.
Stream Live Status Updates
Your two dashboard tabs now show a polished snapshot. The ten-second wait exposed the problem: a one-time page load quickly becomes stale.
Now the Node.js server will push fresh service data through a WebSocket connection. Each tab will interpret the same JSON envelopes as live state.
In this step, get ready to:
- Define rotating service state inside the Node.js server.
- Broadcast snapshot plus live update messages to connected browser clients.
- Render synchronized status changes in two Microsoft Edge tabs.
Model the live service state
The server needs one authoritative record for each simulated service. A deterministic status cycle makes the changes easy to follow across both tabs.
The installed ws package at version 8.21.3 supplies the server-side WebSocket implementation. The browser uses its own native WebSocket client later in this step.
- Stop the running server in the PowerShell terminal by pressing Ctrl+C.
- In server.js, find the two import lines at the top of the file.
- Compare them with this current block:
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
What are these imports for?
- The node:http import creates the existing HTTP server.
- The node:fs/promises import lets that server read public/index.html.
- Replace the current import block with this version:
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import WebSocket, { WebSocketServer } from "ws";
What changed in the imports?
The new import provides WebSocketServer for accepting browser connections. It also provides WebSocket.OPEN for checking whether a client can receive a message.
- In server.js, find the indexUrl declaration.
- Add the service model directly below that declaration by copying this code:
const statusCycle = ["operational", "degraded", "outage"];
const services = [
{
id: "api",
name: "Public API",
status: "operational",
latencyMs: 42,
updatedAt: new Date().toISOString()
},
{
id: "database",
name: "Primary Database",
status: "operational",
latencyMs: 28,
updatedAt: new Date().toISOString()
},
{
id: "worker",
name: "Background Worker",
status: "operational",
latencyMs: 64,
updatedAt: new Date().toISOString()
}
];
let updateNumber = 0;
What does the service model hold?
- The services array is the server's current record of the API, database, and worker.
- The statusCycle array defines the repeating order of service health states.
- The updateNumber value determines which service changes during each interval.
- Each updatedAt value starts as an ISO timestamp generated by the server.
- Save server.js.
- Start the server with the expanded state model by running this command:
npm start
What does this check prove?
The start script runs server.js through Node.js. A successful start confirms that the new import plus service model contain no startup errors.
The terminal shows http://127.0.0.1:8080 again. Your existing dashboard also remains reachable.
Does the server fail to start?
- Check that the new import sits with the other imports at the top of server.js.
- Check each quote plus comma in the services array against the code above.
- Use this prompt for focused help: Help me find the startup error in my Node.js service state model.
Broadcast snapshots and updates
A new browser connection needs a complete snapshot before incremental updates make sense.After that first message, the server can broadcast one changed service every two seconds.
- Stop the running server by pressing Ctrl+C.
- In server.js, find the closing line of the createServer() call.
- Add the WebSocket server plus snapshot function directly below that line by copying this code:
const wss = new WebSocketServer({ server });
function createSnapshot() {
return {
type: "status-snapshot",
generatedAt: new Date().toISOString(),
services
};
}
How does the snapshot work?
- The wss instance shares the existing local server.
- The createSnapshot() function wraps all three services in a status-snapshot envelope.
- The generatedAt field tells the browser when the server created the message.
- Save server.js.
- Check the shared server attachment by running the start command:
npm start
What does this startup confirm?
The matching local URL confirms that the HTTP server accepts the WebSocket attachment. Both protocols now share the same host plus port.
The terminal prints the dashboard URL without a startup error.
Is the WebSocket server attachment failing?
- Confirm that wss is declared after the server constant.
- Confirm that WebSocketServer appears in the import from ws.
- Use this prompt for focused help: Help me attach WebSocketServer to my existing Node.js HTTP server.
- Stop the server by pressing Ctrl+C.
- In server.js, find the closing brace of createSnapshot().
- Add the broadcast function directly below it by copying this code:
function broadcast(payload) {
const message = JSON.stringify(payload);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
How does broadcasting stay safe?
- The JSON.stringify() call converts the message envelope into text for transmission.
- The wss.clients collection contains every browser connected to the server.
- The WebSocket.OPEN check prevents a send attempt on a client that cannot receive data.
- Save server.js.
- Check the broadcast function for startup errors by running the start command:
npm start
What does this check establish?
The server can now serialize an envelope for every open client. A successful start confirms that the broadcast function is valid JavaScript.
The terminal prints the local dashboard URL again.
Does the broadcast function cause an error?
- Check that client.readyState uses three equals signs before WebSocket.OPEN.
- Check that client.send(message) remains inside the readiness condition.
- Use this prompt for focused help: Help me debug my WebSocket broadcast function.
- Stop the server by pressing Ctrl+C.
- In server.js, find the closing brace of broadcast().
- Add the connection handler directly below it by copying this code:
wss.on("connection", (socket) => {
socket.on("error", console.error);
socket.send(JSON.stringify(createSnapshot()));
});
What happens on connection?
Every new browser connection immediately receives the complete snapshot. This gives that tab a current baseline before later updates arrive.
- Save server.js.
- Check the connection handler by running the start command:
npm start
What does this check confirm?
The WebSocket server is ready to accept a connection. Each accepted socket has an error listener plus an initial snapshot message.
The terminal prints http://127.0.0.1:8080 without an error.
Does the connection handler fail?
- Confirm that the event name is spelled connection.
- Confirm that createSnapshot() includes its parentheses inside JSON.stringify().
- Use this prompt for focused help: Help me debug the initial WebSocket snapshot handler.
- Stop the server by pressing Ctrl+C.
- In server.js, find the closing line of the connection handler.
- Add the deterministic update interval directly below it by copying this code:
setInterval(() => {
const service = services[updateNumber % services.length];
const currentStatusIndex = statusCycle.indexOf(service.status);
service.status = statusCycle[(currentStatusIndex + 1) % statusCycle.length];
service.latencyMs = 35 + ((updateNumber * 37) % 220);
service.updatedAt = new Date().toISOString();
updateNumber += 1;
broadcast({
type: "status-update",
generatedAt: new Date().toISOString(),
service
});
}, 2000);
How are live updates generated?
- The modulo calculation selects one service during each interval.
- The next status comes from the repeating statusCycle array.
- The latency formula creates a predictable changing value for the selected service.
- The status-update envelope contains only the service that changed.
- The final 2000 schedules a new update every two seconds.
- Save server.js.
- Start the completed live-update server by running this command:
npm start
What is running now?
The server now updates one service every two seconds. Connected clients can receive each change through the shared WebSocket channel.
The terminal shows the matching local URL. The Node.js process remains active while the interval runs.
Does the interval stop the server?
- Check that setInterval() ends before the existing server.listen() call.
- Check each calculated property against the code above.
- Use this prompt for focused help: Help me debug my deterministic WebSocket update interval.
Connect the browser dashboard
The server is producing messages now, but the page still contains fixed card markup. The browser needs local state plus a message handler that turns each envelope into rendered cards.
- Stop the server by pressing Ctrl+C.
- In public/index.html, find the serviceGrid section containing the three hardcoded cards.
- Replace that entire section with this waiting state:
<section id="serviceGrid" class="grid" aria-live="polite">
<p>Waiting for service data...</p>
</section>
Why replace the hardcoded cards?
The grid now begins with an honest waiting state. Incoming server data becomes the only source used to create cards.
- In public/index.html, find the closing body tag.
- Add this client state block directly above that tag:
<script>
const serviceGrid = document.querySelector("#serviceGrid");
const connectionPill = document.querySelector("#connectionPill");
const connectionText = document.querySelector("#connectionText");
const lastSync = document.querySelector("#lastSync");
const serviceState = new Map();
let socket;
const statusLabels = {
operational: "Operational",
degraded: "Degraded",
outage: "Outage"
};
</script>
What does the client state track?
- The four DOM references point to the grid, connection pill, connection text, and sync label.
- The serviceState Map stores one current service object under each service id.
- The socket variable holds the browser's WebSocket connection.
- The statusLabels object converts internal status values into readable card labels.
- Save public/index.html.
- Start the server with the client scaffold by running this command:
npm start
What does this intermediate check show?
The browser can load the new grid plus client state without a script error. The waiting message is the visible proof that the fixed cards are gone.
- Return to the first Microsoft Edge tab from earlier.
- Refresh the first tab.
You see Waiting for service data... where the three fixed cards used to be.
Do the old cards remain?
- Confirm that every hardcoded article element was removed from the serviceGrid section.
- Confirm that you saved public/index.html before refreshing the tab.
- Use this prompt for focused help: Help me replace my hardcoded service cards with the waiting grid.
- Stop the server by pressing Ctrl+C.
- In public/index.html, find the statusLabels object inside the script.
- Add the rendering functions directly below that object by copying this code:
function renderServices() {
serviceGrid.innerHTML = [...serviceState.values()]
.map((service) => `
<article class="card" data-status="${service.status}">
<div class="status-row">
<h2>${service.name}</h2>
<span class="badge">${statusLabels[service.status]}</span>
</div>
<p class="latency">${service.latencyMs} ms</p>
<p class="updated">
Updated ${new Date(service.updatedAt).toLocaleTimeString()}
</p>
</article>
`)
.join("");
}
function updateLastSync(generatedAt) {
lastSync.textContent = `Last sync ${new Date(generatedAt).toLocaleTimeString()}`;
}
How does the browser render state?
- The spread operation turns the Map values into an array for rendering.
- Each service becomes a card with its current name, status, latency, and timestamp.
- The data-status attribute activates the existing status colors.
- The updateLastSync() function displays the server envelope time in the summary row.
- In the same script, find the closing script tag.
- Add the message handler plus connection code directly above that tag by copying this code:
function handleMessage(event) {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "status-snapshot") {
serviceState.clear();
message.services.forEach((service) => serviceState.set(service.id, service));
renderServices();
updateLastSync(message.generatedAt);
}
if (message.type === "status-update") {
serviceState.set(message.service.id, message.service);
renderServices();
updateLastSync(message.generatedAt);
}
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${window.location.host}`);
socket.addEventListener("message", handleMessage);
How do messages become live cards?
- The handleMessage() function parses the text payload from event.data.
- A status-snapshot message clears the Map before storing all three services.
- A status-update message replaces only the service identified by its id.
- Both message types rerender the grid plus update the sync time.
- The protocol selection supports local ws: traffic plus secure wss: traffic.
- Save public/index.html.
Before you run the completed live version, predict whether both tabs will change at the same moment.
- Start the completed live dashboard by running this command:
npm start
What starts with this command?
The HTTP page plus WebSocket server now run in the same Node.js process. The terminal prints the local URL while the status interval continues in the background.
- Return to the first Microsoft Edge tab.
- Refresh the first tab.
- Return to the second Microsoft Edge tab.
- Refresh the second tab.
- Arrange both tabs so you can compare their service cards.
- Watch both dashboards for at least ten seconds.
Both tabs show the same service changing every two seconds. Its status, latency, update time, and summary time change together.
You have the live channel working: one server update now reaches every open dashboard tab.
Are the cards stuck on the waiting message?
- Confirm that both tabs use http://127.0.0.1:8080.
- Confirm that socket.addEventListener("message", handleMessage) appears before the closing script tag.
- Confirm that the PowerShell terminal still shows the running Node.js process.
- Use this prompt for focused help: Help me debug why my WebSocket dashboard stays on the waiting message.
This interruption is temporary. Your project files remain untouched.
Before you stop the server, predict what the two dashboards will show after their next expected update.
- Stop the server in PowerShell by pressing Ctrl+C.
- Watch both tabs for at least four seconds.
Both dashboards silently freeze on their latest cards. The connection pill gives no useful warning because lifecycle events are not handled yet.
This silent freeze is intentional
The live stream works while the server is available, but the page cannot explain a lost connection. This visible shortfall is the failure mode you will solve next.
- Restore the local Node.js process by running this command:
npm start
Why restart the process?
Restarting returns the local server to the required end state. The existing browser sockets remain disconnected because automatic recovery does not exist yet.
- Refresh the first Microsoft Edge tab.
- Refresh the second Microsoft Edge tab.
- Watch both tabs until the next service update arrives.
The synchronized stream returns after the manual refresh. This proves that live delivery works while automatic recovery remains absent.
Do updates stay frozen after the refresh?
- Confirm that the terminal shows the local dashboard URL after the restart.
- Confirm that each browser tab was refreshed after the server returned.
- Use this prompt for focused help: Help me restore my local WebSocket stream after restarting the server.
✔️ Awesome, I've got everything!
Great work. Save both files while the Node.js process remains active.
ⓧ I'd like to double check the full code
Compare your completed server with this exact version.
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import WebSocket, { WebSocketServer } from "ws";
const HOST = "127.0.0.1";
const PORT = 8080;
const indexUrl = new URL("./public/index.html", import.meta.url);
const statusCycle = ["operational", "degraded", "outage"];
const services = [
{
id: "api",
name: "Public API",
status: "operational",
latencyMs: 42,
updatedAt: new Date().toISOString()
},
{
id: "database",
name: "Primary Database",
status: "operational",
latencyMs: 28,
updatedAt: new Date().toISOString()
},
{
id: "worker",
name: "Background Worker",
status: "operational",
latencyMs: 64,
updatedAt: new Date().toISOString()
}
];
let updateNumber = 0;
const server = createServer(async (request, response) => {
if (request.url !== "/" && request.url !== "/index.html") {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
return;
}
try {
const html = await readFile(indexUrl);
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(html);
} catch (error) {
console.error(error);
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Unable to load the dashboard");
}
});
const wss = new WebSocketServer({ server });
function createSnapshot() {
return {
type: "status-snapshot",
generatedAt: new Date().toISOString(),
services
};
}
function broadcast(payload) {
const message = JSON.stringify(payload);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
wss.on("connection", (socket) => {
socket.on("error", console.error);
socket.send(JSON.stringify(createSnapshot()));
});
setInterval(() => {
const service = services[updateNumber % services.length];
const currentStatusIndex = statusCycle.indexOf(service.status);
service.status = statusCycle[(currentStatusIndex + 1) % statusCycle.length];
service.latencyMs = 35 + ((updateNumber * 37) % 220);
service.updatedAt = new Date().toISOString();
updateNumber += 1;
broadcast({
type: "status-update",
generatedAt: new Date().toISOString(),
service
});
}, 2000);
server.listen(PORT, HOST, () => {
console.log(`Dashboard running at http://${HOST}:${PORT}`);
});
What should the server contain?
The file keeps the original HTTP handler. It adds shared service state, the WebSocket server, both envelope types, broadcasting, and the two-second update interval.
Compare your completed browser page with this exact version.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Service Pulse</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #07111f;
color: #e8eef7;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #153258, #07111f 55%);
}
.shell {
width: min(1050px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0;
}
.topbar,
.summary,
.card {
background: rgba(12, 28, 49, 0.86);
border: 1px solid #294563;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}
.topbar {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
padding: 28px;
border-radius: 22px;
}
.eyebrow {
margin: 0 0 8px;
color: #77b7ff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(2rem, 5vw, 3.8rem); }
.connection {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
border-radius: 999px;
background: #253650;
font-weight: 750;
white-space: nowrap;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #94a3b8;
}
.connection[data-state="connected"] .dot { background: #37d67a; }
.connection[data-state="error"] .dot { background: #ff6577; }
.connection[data-state="reconnecting"] .dot { background: #f6c85f; }
.summary {
display: flex;
justify-content: space-between;
gap: 16px;
margin: 22px 0;
padding: 16px 20px;
border-radius: 16px;
color: #b8c7da;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
.card {
padding: 22px;
border-radius: 18px;
border-top: 4px solid #37d67a;
}
.card[data-status="degraded"] { border-top-color: #f6c85f; }
.card[data-status="outage"] { border-top-color: #ff6577; }
.status-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.card h2 { margin: 0; font-size: 1.15rem; }
.badge {
padding: 6px 9px;
border-radius: 999px;
background: #183c2b;
color: #8cf0b5;
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.card[data-status="degraded"] .badge {
background: #473b19;
color: #ffe18b;
}
.card[data-status="outage"] .badge {
background: #4c2029;
color: #ff9eaa;
}
.latency {
margin: 24px 0 4px;
font-size: 2rem;
font-weight: 850;
}
.updated { margin: 0; color: #8da2ba; font-size: 0.86rem; }
@media (max-width: 760px) {
.topbar,
.summary { align-items: flex-start; flex-direction: column; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Part 1: WebSocket Project</p>
<h1>Service Pulse</h1>
</div>
<div id="connectionPill" class="connection" data-state="connecting">
<span class="dot" aria-hidden="true"></span>
<span id="connectionText">Connecting</span>
</div>
</header>
<section class="summary" aria-label="Dashboard summary">
<span>Three simulated services</span>
<span id="lastSync">Waiting for first snapshot</span>
</section>
<section id="serviceGrid" class="grid" aria-live="polite">
<p>Waiting for service data...</p>
</section>
</main>
<script>
const serviceGrid = document.querySelector("#serviceGrid");
const connectionPill = document.querySelector("#connectionPill");
const connectionText = document.querySelector("#connectionText");
const lastSync = document.querySelector("#lastSync");
const serviceState = new Map();
let socket;
const statusLabels = {
operational: "Operational",
degraded: "Degraded",
outage: "Outage"
};
function renderServices() {
serviceGrid.innerHTML = [...serviceState.values()]
.map((service) => `
<article class="card" data-status="${service.status}">
<div class="status-row">
<h2>${service.name}</h2>
<span class="badge">${statusLabels[service.status]}</span>
</div>
<p class="latency">${service.latencyMs} ms</p>
<p class="updated">
Updated ${new Date(service.updatedAt).toLocaleTimeString()}
</p>
</article>
`)
.join("");
}
function updateLastSync(generatedAt) {
lastSync.textContent = `Last sync ${new Date(generatedAt).toLocaleTimeString()}`;
}
function handleMessage(event) {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "status-snapshot") {
serviceState.clear();
message.services.forEach((service) => serviceState.set(service.id, service));
renderServices();
updateLastSync(message.generatedAt);
}
if (message.type === "status-update") {
serviceState.set(message.service.id, message.service);
renderServices();
updateLastSync(message.generatedAt);
}
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${window.location.host}`);
socket.addEventListener("message", handleMessage);
</script>
</body>
</html>
What should the browser page contain?
The existing layout plus styles remain intact. The script now stores server data in a Map before rendering synchronized cards plus the latest sync time.
Your dashboard now receives one shared stream across multiple tabs. Next, you will make connection failures visible plus recover the stream automatically.
Make Disconnects Visible and Recoverable
Your WebSocket stream already keeps both dashboard tabs synchronized. When the Node.js process stops, both tabs freeze while the connection pill still looks active.
That silent failure hides the dashboard's real condition. In this step, you will connect browser lifecycle events to the pill. You will also add a retry loop that recovers when the server returns.
In this step, get ready to:
- Track the browser connection lifecycle in the dashboard pill.
- Retry the WebSocket connection after an unexpected closure.
- Close the socket safely when the page exits.
Track retry state
A retry loop needs to remember its pending timer. It also needs a flag that distinguishes an unexpected disconnect from a page that is deliberately leaving.
- In public/index.html, find the state declarations immediately below serviceState.
- Confirm that the declarations currently look like this:
const serviceState = new Map();
let socket;
What does the current state hold?
The Map holds the latest service records. The socket variable holds the active browser connection.
- Replace those declarations with this lifecycle state:
const serviceState = new Map();
let socket;
let reconnectTimer;
let allowReconnect = true;
What does the new state track?
- The reconnectTimer variable holds the scheduled retry.
- The allowReconnect flag controls whether a closed socket should start another connection.
- Save public/index.html.
- Switch back to one Microsoft Edge tab.
- Press Ctrl+Shift+J to open the Console tool.
- Check the retry flag by entering this expression:
allowReconnect
What does this check prove?
The browser reads allowReconnect from your updated script. A value of true confirms that the new retry state is available.
You will see true in the Console. The retry state is ready for the connection logic.
Retry flag missing?
- Refresh the dashboard after saving public/index.html.
- Check that allowReconnect uses the same capitalization as the declaration.
- Confirm that both new declarations are inside the existing script element.
- Help me find why allowReconnect is unavailable in my dashboard.
Connect lifecycle events
Browser WebSockets emit lifecycle events as the connection changes. Mapping those events to the existing pill gives the learner an honest view of the channel.
- In public/index.html, find updateLastSync(generatedAt).
- Add this helper immediately below that function:
function setConnectionState(state, label) {
connectionPill.dataset.state = state;
connectionText.textContent = label;
}
What does this helper do?
- The state value updates the pill's data-state attribute. Your existing CSS uses that attribute to choose the dot colour.
- The label value updates the text that the learner sees.
- At the bottom of the script, select the current block that defines protocol.
- Include the current socket assignment in that selection.
- Include the current message listener in that selection.
- Replace the selected block with this connection function:
function connect() {
window.clearTimeout(reconnectTimer);
setConnectionState("connecting", "Connecting");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${window.location.host}`);
socket.addEventListener("open", () => {
setConnectionState("connected", "Connected");
});
socket.addEventListener("message", handleMessage);
socket.addEventListener("error", () => {
setConnectionState("error", "Connection error");
});
socket.addEventListener("close", () => {
if (!allowReconnect) {
setConnectionState("connecting", "Closed");
return;
}
setConnectionState("reconnecting", "Reconnecting");
reconnectTimer = window.setTimeout(connect, 2000);
});
}
connect();
How does the connection recover?
- The function clears any pending retry before it creates a socket. This prevents overlapping retry timers.
- The open listener changes the pill to Connected.
- The error listener reports a connection problem through the same pill.
- The close listener schedules another connection after two seconds when retries are allowed.
- The close listener leaves serviceState untouched. Existing cards remain visible during retries.
- Save public/index.html.
- Refresh each Microsoft Edge tab.
You will see the pill change from Connecting to Connected. The cards continue updating every two seconds in both tabs.
Connection pill stuck?
- Check that connect(); remains at the bottom of the script.
- Check that the existing handleMessage name matches the message listener.
- Confirm that the Node.js process is still running in the terminal from earlier.
- Help me debug a connection pill that stays on Connecting.
Handle page exits and test recovery
A page exit is an intentional connection closure. The page lifecycle handlers stop that closure from creating a pointless retry while the tab is leaving.
- In public/index.html, find the final connect(); call.
- Add these page lifecycle listeners immediately above that call:
window.addEventListener("pagehide", () => {
allowReconnect = false;
window.clearTimeout(reconnectTimer);
socket?.close();
});
window.addEventListener("pageshow", (event) => {
if (event.persisted) {
allowReconnect = true;
connect();
}
});
Why handle page lifecycle events?
- The pagehide listener disables retries before closing the socket.
- Optional chaining lets socket?.close() run safely when no socket exists.
- A persisted pageshow event means the browser restored the page from its history cache. The handler enables retries before reconnecting.
- Save public/index.html.
- Refresh both Microsoft Edge tabs.
Before you interrupt the server, what state do you expect the connection pill to show?
- Stop the running server by pressing Ctrl+C in the PowerShell terminal from earlier.
You will see Reconnecting in both tabs. The latest cards stay visible while the browser retries.
Before you restart the server, do you expect the tabs to recover by themselves?
- Restart the local server by running this command in the same terminal:
npm start
What does this command do?
The npm start command runs the existing start script in package.json. That script launches server.js again.
The retry can take up to two seconds to catch the restored server. That short wait comes from the timer inside connect().
That is the recovery loop working. You will see the terminal print the local dashboard URL. Both pills return to Connected while synchronized card updates resume.
Dashboard still reconnecting?
- Confirm that the terminal shows the dashboard running at the local URL.
- Wait two seconds for the next scheduled connection attempt.
- Check that window.setTimeout(connect, 2000) remains inside the close listener.
- Help me debug why my dashboard stays on Reconnecting after npm start.
✔️ Awesome, I've got everything!
Great work. Your dashboard now reports connection failures honestly. It also recovers without losing the latest service cards.
ⓧ I'd like to double check the full code
Compare your public/index.html with this completed version:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Service Pulse</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #07111f;
color: #e8eef7;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #153258, #07111f 55%);
}
.shell {
width: min(1050px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0;
}
.topbar,
.summary,
.card {
background: rgba(12, 28, 49, 0.86);
border: 1px solid #294563;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
}
.topbar {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
padding: 28px;
border-radius: 22px;
}
.eyebrow {
margin: 0 0 8px;
color: #77b7ff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(2rem, 5vw, 3.8rem); }
.connection {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
border-radius: 999px;
background: #253650;
font-weight: 750;
white-space: nowrap;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #94a3b8;
}
.connection[data-state="connected"] .dot { background: #37d67a; }
.connection[data-state="error"] .dot { background: #ff6577; }
.connection[data-state="reconnecting"] .dot { background: #f6c85f; }
.summary {
display: flex;
justify-content: space-between;
gap: 16px;
margin: 22px 0;
padding: 16px 20px;
border-radius: 16px;
color: #b8c7da;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
.card {
padding: 22px;
border-radius: 18px;
border-top: 4px solid #37d67a;
}
.card[data-status="degraded"] { border-top-color: #f6c85f; }
.card[data-status="outage"] { border-top-color: #ff6577; }
.status-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.card h2 { margin: 0; font-size: 1.15rem; }
.badge {
padding: 6px 9px;
border-radius: 999px;
background: #183c2b;
color: #8cf0b5;
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.card[data-status="degraded"] .badge {
background: #473b19;
color: #ffe18b;
}
.card[data-status="outage"] .badge {
background: #4c2029;
color: #ff9eaa;
}
.latency {
margin: 24px 0 4px;
font-size: 2rem;
font-weight: 850;
}
.updated { margin: 0; color: #8da2ba; font-size: 0.86rem; }
@media (max-width: 760px) {
.topbar,
.summary { align-items: flex-start; flex-direction: column; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Part 1: WebSocket Project</p>
<h1>Service Pulse</h1>
</div>
<div id="connectionPill" class="connection" data-state="connecting">
<span class="dot" aria-hidden="true"></span>
<span id="connectionText">Connecting</span>
</div>
</header>
<section class="summary" aria-label="Dashboard summary">
<span>Three simulated services</span>
<span id="lastSync">Waiting for first snapshot</span>
</section>
<section id="serviceGrid" class="grid" aria-live="polite">
<p>Waiting for service data...</p>
</section>
</main>
<script>
const serviceGrid = document.querySelector("#serviceGrid");
const connectionPill = document.querySelector("#connectionPill");
const connectionText = document.querySelector("#connectionText");
const lastSync = document.querySelector("#lastSync");
const serviceState = new Map();
let socket;
let reconnectTimer;
let allowReconnect = true;
const statusLabels = {
operational: "Operational",
degraded: "Degraded",
outage: "Outage"
};
function renderServices() {
serviceGrid.innerHTML = [...serviceState.values()]
.map((service) => `
<article class="card" data-status="${service.status}">
<div class="status-row">
<h2>${service.name}</h2>
<span class="badge">${statusLabels[service.status]}</span>
</div>
<p class="latency">${service.latencyMs} ms</p>
<p class="updated">
Updated ${new Date(service.updatedAt).toLocaleTimeString()}
</p>
</article>
`)
.join("");
}
function updateLastSync(generatedAt) {
lastSync.textContent = `Last sync ${new Date(generatedAt).toLocaleTimeString()}`;
}
function setConnectionState(state, label) {
connectionPill.dataset.state = state;
connectionText.textContent = label;
}
function handleMessage(event) {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "status-snapshot") {
serviceState.clear();
message.services.forEach((service) => serviceState.set(service.id, service));
renderServices();
updateLastSync(message.generatedAt);
}
if (message.type === "status-update") {
serviceState.set(message.service.id, message.service);
renderServices();
updateLastSync(message.generatedAt);
}
}
function connect() {
window.clearTimeout(reconnectTimer);
setConnectionState("connecting", "Connecting");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${window.location.host}`);
socket.addEventListener("open", () => {
setConnectionState("connected", "Connected");
});
socket.addEventListener("message", handleMessage);
socket.addEventListener("error", () => {
setConnectionState("error", "Connection error");
});
socket.addEventListener("close", () => {
if (!allowReconnect) {
setConnectionState("connecting", "Closed");
return;
}
setConnectionState("reconnecting", "Reconnecting");
reconnectTimer = window.setTimeout(connect, 2000);
});
}
window.addEventListener("pagehide", () => {
allowReconnect = false;
window.clearTimeout(reconnectTimer);
socket?.close();
});
window.addEventListener("pageshow", (event) => {
if (event.persisted) {
allowReconnect = true;
connect();
}
});
connect();
</script>
</body>
</html>
How should you compare it?
Focus on the script near the bottom of the file. Confirm that the lifecycle state appears once. Confirm that connect(); remains the final script statement.
Secret mission
Add a Live Incident Timeline
Current service cards tell you what is happening now. Add a six-entry incident timeline that keeps the newest degraded or outage event at the top.
Clean Up Your Resources
Clean Up Your Resources
Your Node.js dashboard runs entirely on your computer with no cloud resources or ongoing costs. Decide whether to keep the local server running, pause it for later, or delete the project folder.
Resources you used:
- The local realtime-status-dashboard project folder. It contains package.json, package-lock.json, server.js, public/index.html, plus node_modules with ws version 8.21.3.
Keep everything running
No action needed. Choose this if you are still demonstrating synchronized tabs or testing the Incident Timeline.
- Leave the running terminal process active so the service cards keep receiving updates.
- Keep each Microsoft Edge dashboard tab open to demonstrate synchronized service cards.
- Keep the realtime-status-dashboard folder in its current location so every project file remains available.
Pause - I'll come back to this later
Pausing returns control of the terminal. Every project file stays available for later.
- Return to the terminal from earlier.
- Press Ctrl+C to stop the local server.
- Keep the realtime-status-dashboard folder in its current location.
You can step away with the dashboard safely paused. You'll see the terminal prompt again.
Delete - I don't want to use this again
Remove the local dashboard completely when you want a clean start. Only the realtime-status-dashboard folder is affected.
- Return to the terminal from earlier.
- Press Ctrl+C to stop the local server.
- Use Windows File Explorer to locate the realtime-status-dashboard folder in the location where you created it.
- Delete the realtime-status-dashboard folder in Windows File Explorer.
- Confirm that the realtime-status-dashboard folder no longer appears in that location.
That is the full local cleanup done. The dashboard source files plus the installed dependency are removed together.
Nice Work!
Nice Work!
You did it! Service Pulse is now a demonstrable real-time dashboard backed by your local server.
You've learned how to:
- Built a local Node.js HTTP server with an attached WebSocket server for broadcasting live service updates.
- Designed JSON message envelopes that synchronize service status across multiple Microsoft Edge tabs.
- Made connection failures visible with connection lifecycle states. Added automatic reconnection so live updates resume when the server returns.
- Completed the optional Secret Mission by adding a bounded Incident Timeline. It records degraded or outage events newest first. It keeps the six most recent incidents.
Ready to quiz yourself?