WebSocket Event Control Room

Build a Go WebSocket dashboard with bounded fan-out and recovery.

Introduction

30 Second Summary

Live pages feel effortless while every connection is healthy. The difficult moments arrive when one browser falls behind or a connection disappears without warning.

In this project, you will build a local event control room with Go and WebSocket. You will use bounded queues to keep healthy browser tabs responsive when one client falls behind.

What You'll Build

You publish an event in one browser tab and watch every healthy tab update immediately.

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

  • A live event fan-out where several browser tabs exchange JSON events through one Go server. You can name each sender and watch every healthy tab receive the same event.
  • A visible protocol trail that lets you inspect the HTTP Upgrade response. You can distinguish an application message from the RFC 6455 frames carrying it.
  • A resilient connection demo where a simulated slow client closes with code 1013 while healthy tabs keep receiving events. You can restart the server and watch latency updates become reconnect attempts before delivery resumes.
  • Secret Mission: Attack the protocol boundary by sending an unsupported binary message and attempting a foreign-origin connection. You will classify why one failure uses a WebSocket close frame while the other fails during the opening handshake.

Are there any prerequisites?

You need a Linux environment with Go 1.23 or newer. You also need Python 3, browser developer tools, a terminal, and a code editor.

You should be comfortable writing Go applications. No previous TCP or WebSocket implementation experience is required.

Before We Start

Before any hands-on work, lock in the goal of your local Go WebSocket Event Control Room: make HTTP upgrades, messages, heartbeats, closures, reconnects, and failure isolation observable. Bounded per-client queues keep a slow consumer from blocking healthy clients or exhausting server memory.

Verify the Toolchain and Pin the Module

A WebSocket lab is easier to trust when every run starts from a known toolchain. A compatible Go release keeps the project aligned with the pinned library.

A reproducible Go module records the required language level. It also prevents dependency API drift from distracting you during later protocol work.

In this step, get ready to:
  • Verify that the installed Go version is 1.23 or newer.
  • Create the websocket-control-room module structure.
  • Pin github.com/coder/websocket v1.8.15 as the selected dependency.
Verify the Go version

The installed Go version must meet the library's minimum requirement. This check catches an incompatible toolchain before it can create confusing dependency errors.

  • Check the installed Go version by running this command:
go version

What should you look for?

The output reports the active Go version. Continue when the reported version is 1.23 or newer.

✔️ I see a compatible Go version

Your Go toolchain is ready. You can now create the module with the required compatibility level.

ⓧ I see an older Go version

The installed version cannot support this pinned project baseline. Update Go before you create the module.

  • Follow the official Go installation guide for your Linux environment.
  • Return to the version check above after the update completes.

Need help with the update?

Linux package sources can provide different Go versions. Help me update Go to the required version on my Linux system.

Create the module structure

The module needs a predictable location before Go creates its metadata. The empty web directory reserves a home for the browser dashboard assets you add later.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The command changes the terminal's current location to your Desktop. The new project directory appears somewhere easy to find.

  • Create the project directory and enter it by running these commands:
mkdir websocket-control-room
cd websocket-control-room
pwd

What do these commands do?

  • The first command creates the websocket-control-room directory on your Desktop.
  • The second command makes that directory the terminal's current location.
  • The final command prints the current path so you can confirm it ends with websocket-control-room.

Directory command not completing?

A directory with the same name may already exist on your Desktop. Confirm that the terminal path ends with websocket-control-room before continuing.

Make sure your Linux account can create directories on the Desktop. Help me diagnose why I cannot create or enter the project directory.

  • Create the browser asset directory and initialize the module by running these commands:
mkdir web
go mod init websocket-control-room
ls

What do these commands create?

  • The web directory starts empty for the dashboard files.
  • The module command creates go.mod with websocket-control-room as the module path.
  • The directory listing confirms that go.mod and web now exist.

Module initialization failing?

Confirm that your terminal is inside the Desktop's websocket-control-room directory. An existing go.mod file means the module has already been initialized.

Check that the earlier version command completed successfully. Help me troubleshoot this Go module initialization.

  • Set the module's Go directive to 1.23 by running these commands:
go mod edit -go=1.23
cat go.mod

What changed in the module file?

  • The edit command records 1.23 as the project's language compatibility level.
  • The display command prints go.mod so you can inspect the module path and Go directive.

Go directive not showing?

Confirm that go.mod is in the current directory. Check that the directive contains 1.23 without extra characters.

Run the module edit from the websocket-control-room directory. Help me fix the Go directive in go.mod.

Pin and confirm the WebSocket module

A version pin gives every project run the same library API. The selected version is v1.8.15.

  • Download the dependency and pin the selected version by running this command:
go get github.com/coder/websocket@v1.8.15

What does this command do?

  • Go downloads version v1.8.15 of the module from its canonical path.
  • Go records the dependency in go.mod.
  • Go writes verified dependency checksums to go.sum.

Dependency download not completing?

Confirm that your terminal has network access. Make sure the terminal remains inside websocket-control-room.

A proxy or module-cache setting can also interrupt the download. Help me troubleshoot this Go dependency download.

The project intentionally depends on this library directly. The module file should therefore list it as a direct requirement.

  • Record the dependency as a direct requirement by running this command:
go mod edit -require=github.com/coder/websocket@v1.8.15

Why record a direct requirement?

The command keeps the dependency declaration aligned with the finished application. This produces the exact go.mod baseline used throughout the project.

✔️ Awesome, I've got everything!

Your module files are pinned. The empty web directory is also ready for the dashboard assets.

ⓧ I'd like to double check the full code

Compare your go.mod file with this complete version.

module websocket-control-room

go 1.23

require github.com/coder/websocket v1.8.15

What should match in go.mod?

The module path must be websocket-control-room. The Go directive and dependency version must match the reference exactly.

Compare your go.sum file with these verified checksum entries.

github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=

What should match in go.sum?

Both checksum lines must reference v1.8.15. These entries let Go verify the downloaded module content.

Before you run the final check, do you expect the toolchain and dependency outputs to match both pinned versions?

  • Confirm the selected toolchain and project artifacts by running these commands:
go version
go list -m github.com/coder/websocket
cat go.mod
cat go.sum
ls

What does the final check prove?

  • The first command confirms that the active Go release is 1.23 or newer.
  • The module query prints github.com/coder/websocket v1.8.15.
  • The file output confirms the exact module directive and checksums.
  • The directory listing includes go.mod, go.sum, and web.

That is the reproducible baseline complete. Your terminal remains inside websocket-control-room with the required module version pinned.

Final check not matching?

Confirm that the active terminal path ends with websocket-control-room. A module query from another directory can report a different project state.

Compare both module files with the reference tab above. Help me diagnose my final Go module verification output.

Your toolchain and module are ready. Next, you will build the first live browser-to-Go WebSocket connection.

Build the First Live Connection

Your pinned Go module gives the control room a reproducible foundation. The next milestone is a live browser connection you can inspect from end to end.

You will build a small WebSocket echo server that accepts an HTTP upgrade. A dashboard will send a JSON event and display the text message returned through the same connection.

In this step, get ready to:
  • Build a Go server that accepts WebSocket connections and echoes text messages.
  • Create a browser dashboard for sending events and viewing connection activity.
  • Inspect the opening handshake and identify its HTTP status.
Build the echo server

The first server keeps each connection self-contained. Its read loop waits for text from one browser before writing the same payload back to that browser.

  • In the code editor from earlier, create main.go inside the websocket-control-room directory.
  • Add the WebSocket handler to main.go by pasting this code:
package main

import (
	"context"
	"log"
	"net/http"
	"time"

	"github.com/coder/websocket"
)

func serveWS(w http.ResponseWriter, r *http.Request) {
	conn, err := websocket.Accept(w, r, nil)
	if err != nil {
		log.Printf("WebSocket handshake rejected: %v", err)
		return
	}
	defer conn.CloseNow()

	for {
		messageType, payload, err := conn.Read(context.Background())
		if err != nil {
			return
		}
		if messageType != websocket.MessageText {
			_ = conn.Close(websocket.StatusUnsupportedData, "text messages only")
			return
		}
		if err := conn.Write(context.Background(), websocket.MessageText, payload); err != nil {
			return
		}
	}
}

What does this handler do?

  • The websocket.Accept call completes an allowed opening handshake and returns the persistent connection.
  • The continuous Read loop keeps processing messages until the browser disconnects or an operation fails.
  • The MessageText check enforces the dashboard's text-message contract.
  • The Write call sends the received payload back through the same connection.
  • Save main.go.
  • Confirm the editor shows one complete serveWS function beneath the import block.

Seeing errors in the handler?

Check that main.go sits beside go.mod. Confirm that every imported package appears inside the same import block.

Compare the closing braces around the loop and function. Help me check this WebSocket handler.

The handler needs an HTTP server to route browser requests to it. The same server also exposes the files inside web.

  • Add the server entry point beneath serveWS in main.go by pasting this code:
func main() {
	http.HandleFunc("/ws", serveWS)
	http.Handle("/", http.FileServer(http.Dir("./web")))
	server := &http.Server{Addr: "127.0.0.1:8080", ReadHeaderTimeout: 5 * time.Second}
	log.Printf("Event Control Room available at http://127.0.0.1:8080")
	log.Fatal(server.ListenAndServe())
}

How does the server route requests?

  • Requests for /ws reach the WebSocket handler.
  • Other requests are served from the ./web directory.
  • The server listens on the local address 127.0.0.1:8080.
  • The read-header timeout limits how long an incomplete HTTP request can occupy the server.
  • Save main.go.
  • Resolve the module imports and start the server by running these commands in the terminal from earlier:
go mod tidy
go run .

What do these commands do?

The first command synchronizes the module files with the packages imported by main.go. The second command compiles the current package and starts its server.

The running process keeps control of this terminal. Press Ctrl+C when you need to stop it.

Good. The terminal now reports that the Event Control Room is available at http://127.0.0.1:8080.

Server not starting?

Confirm the terminal is still inside websocket-control-room. Check that main.go and go.mod are listed there.

If the address is already occupied, stop the process currently using the local port before retrying. Help me diagnose the Go server startup failure.

✔️ Awesome, I've got everything!

Your echo server is running. Keep its terminal active while you build the dashboard.

ⓧ I'd like to double check the full code

package main

import (
	"context"
	"log"
	"net/http"
	"time"

	"github.com/coder/websocket"
)

func serveWS(w http.ResponseWriter, r *http.Request) {
	conn, err := websocket.Accept(w, r, nil)
	if err != nil {
		log.Printf("WebSocket handshake rejected: %v", err)
		return
	}
	defer conn.CloseNow()

	for {
		messageType, payload, err := conn.Read(context.Background())
		if err != nil {
			return
		}
		if messageType != websocket.MessageText {
			_ = conn.Close(websocket.StatusUnsupportedData, "text messages only")
			return
		}
		if err := conn.Write(context.Background(), websocket.MessageText, payload); err != nil {
			return
		}
	}
}

func main() {
	http.HandleFunc("/ws", serveWS)
	http.Handle("/", http.FileServer(http.Dir("./web")))
	server := &http.Server{Addr: "127.0.0.1:8080", ReadHeaderTimeout: 5 * time.Second}
	log.Printf("Event Control Room available at http://127.0.0.1:8080")
	log.Fatal(server.ListenAndServe())
}
Create the dashboard

The browser needs controls for composing an event and two visible output areas. One output shows echoed events while the other records connection lifecycle changes.

  • In the code editor's file sidebar, create index.html inside the existing web directory.
  • Build the initial dashboard structure in web/index.html by pasting this markup:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>WebSocket Event Control Room</title>
</head>
<body>
  <main class="shell">
    <header>
      <div><p class="eyebrow">LOCAL REAL-TIME LAB</p><h1>WebSocket Event Control Room</h1></div>
      <div class="telemetry"><span id="status" class="status connecting">connecting</span></div>
    </header>
    <section class="panel controls">
      <label>Sender<input id="sender" value="operator" maxlength="32"></label>
      <form id="event-form"><label>Event message<input id="message" maxlength="256" placeholder="Deploy started" required></label><button type="submit">Send event</button></form>
    </section>
    <section class="grid">
      <article class="panel"><h2>Broadcast events</h2><ol id="events" class="feed"></ol></article>
      <article class="panel"><h2>Connection log</h2><ol id="connection-log" class="feed compact"></ol></article>
    </section>
  </main>
</body>
</html>

How is the dashboard organized?

  • The status element exposes the current connection state.
  • The event-form collects a sender name and event message.
  • The events list displays messages returned by the server.
  • The connection-log list records browser connection events.
  • Save web/index.html.
  • Enter http://127.0.0.1:8080 in your browser's address bar.
  • Press Enter to load the dashboard.

You should see the Event Control Room heading, sender input, event-message input, button, and two empty output lists. The page is plain because its stylesheet comes next.

Dashboard not loading?

Confirm the server terminal still shows the running process. Check that the file path is exactly web/index.html.

Reload the local address after saving the file. Help me find why the local dashboard is not loading.

The stylesheet turns the raw form into a readable control room. Its status colors also make connection changes visible without relying only on the log.

  • In the code editor's file sidebar, create style.css inside the existing web directory.
  • Style the dashboard in web/style.css by pasting this code:
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #07111f; color: #e7eef8; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top right, #12365a 0, transparent 35%), #07111f; }
.shell { width: min(1050px, 92vw); margin: 0 auto; padding: 48px 0; }
header, .controls, .telemetry, #event-form { display: flex; align-items: end; gap: 16px; }
header { justify-content: space-between; align-items: center; margin-bottom: 24px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 3.5rem); }
h2 { font-size: 1rem; letter-spacing: 0.08em; text-transform: uppercase; }
.eyebrow { margin-bottom: 8px; color: #65d9ff; font-size: 0.75rem; letter-spacing: 0.18em; }
.panel { border: 1px solid #28445f; border-radius: 16px; background: rgb(10 27 45 / 88%); padding: 20px; box-shadow: 0 18px 60px rgb(0 0 0 / 24%); }
.controls { flex-wrap: wrap; margin-bottom: 20px; }
label { display: grid; gap: 7px; color: #a9bdd1; font-size: 0.8rem; }
input, button { min-height: 42px; border-radius: 9px; border: 1px solid #355673; font: inherit; }
input { min-width: 210px; padding: 0 12px; background: #071421; color: #f5f9ff; }
button { padding: 0 16px; cursor: pointer; border-color: #4cc9f0; background: #168aad; color: white; font-weight: 700; }
.grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 20px; }
.telemetry { align-items: center; color: #a9bdd1; font-size: 0.85rem; }
.status { display: inline-block; border-radius: 999px; padding: 6px 10px; background: #3d4650; color: white; font-weight: 700; }
.status.open { background: #147d64; }
.status.closed { background: #9d3345; }
.feed { min-height: 230px; max-height: 430px; overflow: auto; margin: 0; padding-left: 22px; }
.feed li { padding: 9px 4px; border-bottom: 1px solid #213a51; overflow-wrap: anywhere; }
.feed.compact { color: #a9bdd1; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.78rem; }
@media (max-width: 760px) { header, .grid { display: grid; grid-template-columns: 1fr; } .telemetry { justify-content: start; } }

What does this stylesheet change?

  • The layout places the event feed beside the connection log on wider screens.
  • The panel rules separate controls and output into bordered cards.
  • The status classes provide distinct colors for open and closed connections.
  • The responsive rule stacks the dashboard on narrow screens.
  • Save web/style.css.
  • In web/index.html, locate the closing </head> tag.
  • Add the stylesheet link directly above that tag:
  <link rel="stylesheet" href="/style.css">
</head>

How does the stylesheet load?

The link asks the Go file server for /style.css. The browser applies the returned CSS to the dashboard markup.

  • Save web/index.html.
  • Refresh the dashboard in your browser.

You should see a dark control room with bordered panels and a two-column output grid. The status pill still reads connecting because the browser client comes next.

Styles not appearing?

Confirm the stylesheet is saved at web/style.css. Check that its link uses the exact path /style.css.

Refresh after saving both files. Help me diagnose the missing dashboard styles.

✔️ The dashboard is styled

The browser now shows the dark Event Control Room layout. Keep the server and browser running.

ⓧ I'd like to double check the stylesheet

:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #07111f; color: #e7eef8; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top right, #12365a 0, transparent 35%), #07111f; }
.shell { width: min(1050px, 92vw); margin: 0 auto; padding: 48px 0; }
header, .controls, .telemetry, #event-form { display: flex; align-items: end; gap: 16px; }
header { justify-content: space-between; align-items: center; margin-bottom: 24px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 3.5rem); }
h2 { font-size: 1rem; letter-spacing: 0.08em; text-transform: uppercase; }
.eyebrow { margin-bottom: 8px; color: #65d9ff; font-size: 0.75rem; letter-spacing: 0.18em; }
.panel { border: 1px solid #28445f; border-radius: 16px; background: rgb(10 27 45 / 88%); padding: 20px; box-shadow: 0 18px 60px rgb(0 0 0 / 24%); }
.controls { flex-wrap: wrap; margin-bottom: 20px; }
label { display: grid; gap: 7px; color: #a9bdd1; font-size: 0.8rem; }
input, button { min-height: 42px; border-radius: 9px; border: 1px solid #355673; font: inherit; }
input { min-width: 210px; padding: 0 12px; background: #071421; color: #f5f9ff; }
button { padding: 0 16px; cursor: pointer; border-color: #4cc9f0; background: #168aad; color: white; font-weight: 700; }
.grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 20px; }
.telemetry { align-items: center; color: #a9bdd1; font-size: 0.85rem; }
.status { display: inline-block; border-radius: 999px; padding: 6px 10px; background: #3d4650; color: white; font-weight: 700; }
.status.open { background: #147d64; }
.status.closed { background: #9d3345; }
.feed { min-height: 230px; max-height: 430px; overflow: auto; margin: 0; padding-left: 22px; }
.feed li { padding: 9px 4px; border-bottom: 1px solid #213a51; overflow-wrap: anywhere; }
.feed.compact { color: #a9bdd1; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.78rem; }
@media (max-width: 760px) { header, .grid { display: grid; grid-template-columns: 1fr; } .telemetry { justify-content: start; } }
Connect and inspect the upgrade

The browser client turns the status pill and connection log into live protocol signals. Its connection URL uses the current page host, which keeps the WebSocket request on the same origin as the dashboard.

  • In the code editor's file sidebar, create app.js inside the existing web directory.
  • Open the connection and render its lifecycle events in web/app.js by pasting this code:
const statusElement = document.querySelector("#status");
const senderElement = document.querySelector("#sender");
const messageElement = document.querySelector("#message");
const formElement = document.querySelector("#event-form");
const eventsElement = document.querySelector("#events");
const connectionLogElement = document.querySelector("#connection-log");

const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
window.controlSocket = socket;

function appendConnectionLog(message) {
  const item = document.createElement("li");
  item.textContent = `${new Date().toLocaleTimeString()} ${message}`;
  connectionLogElement.prepend(item);
}

socket.addEventListener("open", () => { statusElement.textContent = "open"; statusElement.className = "status open"; appendConnectionLog("connection open"); });
socket.addEventListener("message", (messageEvent) => {
  const event = JSON.parse(messageEvent.data);
  const item = document.createElement("li");
  item.textContent = `${event.sentAt || "now"} ${event.sender ? `${event.sender}: ` : ""}${event.message || event.type}`;
  eventsElement.prepend(item);
});
socket.addEventListener("error", () => appendConnectionLog("connection error"));
socket.addEventListener("close", (closeEvent) => { statusElement.textContent = "closed"; statusElement.className = "status closed"; appendConnectionLog(`closed code=${closeEvent.code} clean=${closeEvent.wasClean} reason=${closeEvent.reason || "none"}`); });

What does the connection code do?

  • The URL uses ws: or wss: to match the page protocol.
  • The browser starts connecting as soon as it creates the socket.
  • The open event changes the status pill and records a successful connection.
  • The remaining listeners render returned messages and record connection failures or closures.
  • Save web/app.js.
  • In web/index.html, locate the closing </body> tag.
  • Add the client script directly above that tag:
  <script src="/app.js"></script>
</body>

How does the client start?

The script runs after the dashboard markup has loaded. This lets each selector find its matching page element before the connection opens.

  • Save web/index.html.
  • Refresh the dashboard in your browser.

You should see the status change to open. The connection log should contain a new connection-open entry.

Connection staying closed?

Confirm the Go server is still running at the local address. Check that the script path in web/index.html is exactly /app.js.

Make sure the client URL ends with /ws. Help me diagnose the failed browser connection.

The final client function sends the form data only after the socket reaches its open state. The server then returns the same serialized payload through that connection.

  • Add the send function and form handler beneath the close listener in web/app.js by pasting this code:
function sendEvent(message) {
  if (!socket || socket.readyState !== WebSocket.OPEN) {
    appendConnectionLog("send rejected because the socket is not open");
    return;
  }
  socket.send(JSON.stringify({ sender: senderElement.value.trim() || "anonymous", message }));
}

formElement.addEventListener("submit", (submitEvent) => {
  submitEvent.preventDefault();
  const message = messageElement.value.trim();
  if (message) {
    sendEvent(message);
    messageElement.value = "";
    messageElement.focus();
  }
});

How does the event travel?

  • The ready-state check prevents a send before the connection is open.
  • The form handler converts the sender and message into JSON text.
  • The server reads that text payload and writes it back unchanged.
  • The message listener parses the returned JSON before adding it to the event feed.
  • Save web/app.js.

✔️ My files are ready

The dashboard now has its markup, styles, and browser connection logic. Continue to the live protocol check.

ⓧ 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>WebSocket Event Control Room</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <main class="shell">
    <header>
      <div><p class="eyebrow">LOCAL REAL-TIME LAB</p><h1>WebSocket Event Control Room</h1></div>
      <div class="telemetry"><span id="status" class="status connecting">connecting</span></div>
    </header>
    <section class="panel controls">
      <label>Sender<input id="sender" value="operator" maxlength="32"></label>
      <form id="event-form"><label>Event message<input id="message" maxlength="256" placeholder="Deploy started" required></label><button type="submit">Send event</button></form>
    </section>
    <section class="grid">
      <article class="panel"><h2>Broadcast events</h2><ol id="events" class="feed"></ol></article>
      <article class="panel"><h2>Connection log</h2><ol id="connection-log" class="feed compact"></ol></article>
    </section>
  </main>
  <script src="/app.js"></script>
</body>
</html>
const statusElement = document.querySelector("#status");
const senderElement = document.querySelector("#sender");
const messageElement = document.querySelector("#message");
const formElement = document.querySelector("#event-form");
const eventsElement = document.querySelector("#events");
const connectionLogElement = document.querySelector("#connection-log");

const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
window.controlSocket = socket;

function appendConnectionLog(message) {
  const item = document.createElement("li");
  item.textContent = `${new Date().toLocaleTimeString()} ${message}`;
  connectionLogElement.prepend(item);
}

socket.addEventListener("open", () => { statusElement.textContent = "open"; statusElement.className = "status open"; appendConnectionLog("connection open"); });
socket.addEventListener("message", (messageEvent) => {
  const event = JSON.parse(messageEvent.data);
  const item = document.createElement("li");
  item.textContent = `${event.sentAt || "now"} ${event.sender ? `${event.sender}: ` : ""}${event.message || event.type}`;
  eventsElement.prepend(item);
});
socket.addEventListener("error", () => appendConnectionLog("connection error"));
socket.addEventListener("close", (closeEvent) => { statusElement.textContent = "closed"; statusElement.className = "status closed"; appendConnectionLog(`closed code=${closeEvent.code} clean=${closeEvent.wasClean} reason=${closeEvent.reason || "none"}`); });

function sendEvent(message) {
  if (!socket || socket.readyState !== WebSocket.OPEN) {
    appendConnectionLog("send rejected because the socket is not open");
    return;
  }
  socket.send(JSON.stringify({ sender: senderElement.value.trim() || "anonymous", message }));
}

formElement.addEventListener("submit", (submitEvent) => {
  submitEvent.preventDefault();
  const message = messageElement.value.trim();
  if (message) {
    sendEvent(message);
    messageElement.value = "";
    messageElement.focus();
  }
});

Before you test the full path, predict where the event will appear after the server receives it.

  • Return to the dashboard in your browser.
  • Open your browser's Developer Tools using its menu.
  • Select the Network inspector.
  • Refresh the dashboard to capture a new connection.
  • Enter hello control room in the Event message field.
  • Click Send event.

The event feed should show operator: hello control room. That entry proves the browser sent JSON text and received the echoed payload.

  • Select the /ws request in the Network inspector.
  • Confirm its HTTP status is 101.
  • Inspect the request's WebSocket messages to find the event you sent.

The 101 Switching Protocols response confirms that the HTTP opening handshake became a persistent WebSocket connection. Your first live protocol path is working.

Connection, message, or frame?

The connection is the long-lived communication path carried over TCP after the HTTP upgrade. It can carry multiple messages in both directions.

Your JSON event is one application message. RFC 6455 transports that message inside one or more data frames while control frames handle protocol duties such as closing and Ping/Pong.

Missing the echoed event or handshake?

Refresh while the Network inspector is recording so it captures the opening request. Confirm the dashboard status reads open before sending.

Check the browser console for a script error if the form does not respond. Help me trace this WebSocket event path.

Your browser and Go server now exchange observable events through one live connection. Next, you will open a second connection and test whether this echo design can deliver events between tabs.

Prove Echo Is Not Broadcast

Your first dashboard now completes a WebSocket opening handshake. It echoes each JSON event back to its browser tab.

That one-tab success leaves the fan-out question unanswered. Two independent connections expose whether the current handler can share an event beyond the socket it accepted.

In this step, get ready to:
  • Prepare two dashboard tabs with distinct sender names.
  • Compare event delivery across two separate connections.
  • Identify the ownership gap in the echo design.
Open two named dashboard tabs

Every browser tab creates a separate WebSocket connection to /ws. The sender name gives each test client a visible identity.

  • Switch back to the dashboard tab from earlier.
  • Enter Alpha in the Sender field.
  • Open a second browser tab from the browser tab bar.
  • Load http://127.0.0.1:8080 in the second tab.
  • Enter Beta in the Sender field.

Both dashboards are now active. Each tab represents a separate browser client.

Run the fan-out test

An HTTP Upgrade turns each /ws request into a persistent connection. Browser DevTools lets you confirm that Alpha and Beta completed separate handshakes.

  • Switch to the Alpha tab.
  • Return to the DevTools Network inspector from earlier.
  • Select the /ws request.

Alpha's inspector shows its own 101 Switching Protocols response.

  • Switch to the Beta tab.
  • Open DevTools using the same browser method from earlier.
  • Select the Network inspector.
  • Select the /ws request.

Beta's inspector shows another /ws request with a 101 Switching Protocols response. The browser has established two independent connections.

What do the two handshakes prove?

Each successful upgrade belongs to one browser-server connection. The two connections have no shared application owner in the current echo design.

Before you send the event, predict whether one event list or both event lists will change.

  • Switch to the Alpha tab.
  • Enter hello from Alpha in the Event message field.
  • Click Send event.
  • Switch to the Beta tab.

Alpha's event list logs hello from Alpha. Beta's event list does not show that event.

This is the intended shortfall. The handler writes the payload back through Alpha's own socket.

Seeing the event in the wrong tab?

Confirm that the Alpha test was sent from the tab whose Sender field contains Alpha. Check that each DevTools inspector shows a separate /ws request.

If both tabs receive the event, your server may already contain shared broadcast logic. Help me verify that my current Go WebSocket handler still echoes only to its accepted connection.

Confirm the ownership gap

Connection ownership determines where the server can write. A handler that knows one socket has no complete client set for application fan-out.

Before you send Beta's event, predict which event list will change.

  • Enter hello from Beta in Beta's Event message field.
  • Click Send event.
  • Switch to the Alpha tab.

Beta logs hello from Beta. Alpha's event list does not show Beta's event.

  • Confirm Alpha still shows an open connection.
  • Switch to the Beta tab.
  • Confirm Beta still shows an open connection.

Both tabs remain open as independent WebSocket connections. Their event histories contain only the messages sent through their own sockets.

You have exposed the ownership boundary behind connection-local echo. Next, you will give the complete client set one owner so events can reach every healthy connection.

Build a Bounded Broadcast Hub

Alpha and Beta exposed the limit of connection-local echo. Each WebSocket handler knows only its own socket.

A single Go goroutine can own the complete client set. A bounded queue gives each client an isolated backpressure boundary.

In this step, get ready to:
  • Move connection ownership into one broadcast hub.
  • Give each client a buffered outbound queue with capacity 8.
  • Prove that a slow client can close without delaying healthy clients.
Give the hub connection ownership

The hub owns one map containing every connected client. Its channels turn registration events and outbound messages into work handled by one goroutine.

Why one owner for the client map?

Only the hub goroutine reads from or writes to the client map. This ownership rule keeps connection changes ordered without spreading map synchronization across every handler.

Each client gets a dedicated writer pump. A blocked writer can only fill that client's queue.

  • Switch back to the terminal running the echo server.
  • Press Ctrl+C to stop the current process.
  • In the code editor from earlier, select the entire contents of main.go.
  • Replace the selected code with the bounded-hub implementation in the full-code tab below.

✔️ Awesome, I've got everything!

Great. Double-check that the previous echo handler has been replaced throughout main.go.

ⓧ I'd like to double check the full code

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"strings"
	"time"

	"github.com/coder/websocket"
)

const (
	sendQueueSize    = 8
	maxMessageBytes  = int64(1024)
	operationTimeout = 2 * time.Second
	slowClientDelay  = 500 * time.Millisecond
)

type inboundEvent struct {
	Sender  string `json:"sender"`
	Message string `json:"message"`
}

type outboundEvent struct {
	Type      string `json:"type"`
	Sender    string `json:"sender,omitempty"`
	Message   string `json:"message,omitempty"`
	SentAt    string `json:"sentAt,omitempty"`
	LatencyMS int64  `json:"latencyMs,omitempty"`
}

type client struct {
	conn  *websocket.Conn
	send  chan []byte
	delay time.Duration
}

type hub struct {
	register   chan *client
	unregister chan *client
	broadcast  chan []byte
}

func newHub() *hub {
	return &hub{register: make(chan *client), unregister: make(chan *client), broadcast: make(chan []byte)}
}

func (h *hub) run() {
	clients := make(map[*client]struct{})
	for {
		select {
		case c := <-h.register:
			clients[c] = struct{}{}
		case c := <-h.unregister:
			if _, exists := clients[c]; exists {
				delete(clients, c)
				close(c.send)
			}
		case message := <-h.broadcast:
			for c := range clients {
				select {
				case c.send <- message:
				default:
					delete(clients, c)
					go func(slow *client) {
						_ = slow.conn.Close(websocket.StatusTryAgainLater, "client is too slow")
						close(slow.send)
					}(c)
				}
			}
		}
	}
}

func (h *hub) serveWS(w http.ResponseWriter, r *http.Request) {
	conn, err := websocket.Accept(w, r, nil)
	if err != nil {
		log.Printf("WebSocket handshake rejected: %v", err)
		return
	}
	defer conn.CloseNow()
	conn.SetReadLimit(maxMessageBytes)

	c := &client{conn: conn, send: make(chan []byte, sendQueueSize)}
	if r.URL.Query().Get("slow") == "1" {
		c.delay = slowClientDelay
	}
	welcome, err := json.Marshal(outboundEvent{Type: "system", Message: "connected", SentAt: time.Now().UTC().Format(time.RFC3339)})
	if err != nil {
		log.Printf("Could not encode welcome event: %v", err)
		return
	}
	c.send <- welcome
	h.register <- c

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	errors := make(chan error, 2)
	go func() { errors <- h.readPump(ctx, c) }()
	go func() { errors <- h.writePump(ctx, c) }()
	firstError := <-errors
	cancel()
	h.unregister <- c
	log.Printf("WebSocket session ended: %v", firstError)
}

func (h *hub) readPump(ctx context.Context, c *client) error {
	for {
		messageType, payload, err := c.conn.Read(ctx)
		if err != nil {
			return err
		}
		if messageType != websocket.MessageText {
			return c.conn.Close(websocket.StatusUnsupportedData, "text messages only")
		}
		var incoming inboundEvent
		if err := json.Unmarshal(payload, &incoming); err != nil {
			return c.conn.Close(websocket.StatusPolicyViolation, "invalid JSON event")
		}
		incoming.Sender = strings.TrimSpace(incoming.Sender)
		incoming.Message = strings.TrimSpace(incoming.Message)
		if incoming.Sender == "" || len(incoming.Sender) > 32 {
			return c.conn.Close(websocket.StatusPolicyViolation, "sender must be 1 to 32 bytes")
		}
		if incoming.Message == "" || len(incoming.Message) > 256 {
			return c.conn.Close(websocket.StatusPolicyViolation, "message must be 1 to 256 bytes")
		}
		outgoing, err := json.Marshal(outboundEvent{Type: "event", Sender: incoming.Sender, Message: incoming.Message, SentAt: time.Now().UTC().Format(time.RFC3339)})
		if err != nil {
			return err
		}
		select {
		case h.broadcast <- outgoing:
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func (h *hub) writePump(ctx context.Context, c *client) error {
	for {
		select {
		case message, open := <-c.send:
			if !open {
				return nil
			}
			if c.delay > 0 {
				select {
				case <-time.After(c.delay):
				case <-ctx.Done():
					return ctx.Err()
				}
			}
			if err := writeText(ctx, c.conn, message); err != nil {
				return err
			}
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func writeText(ctx context.Context, conn *websocket.Conn, payload []byte) error {
	writeContext, cancelWrite := context.WithTimeout(ctx, operationTimeout)
	defer cancelWrite()
	return conn.Write(writeContext, websocket.MessageText, payload)
}

func main() {
	h := newHub()
	go h.run()
	mux := http.NewServeMux()
	mux.HandleFunc("/ws", h.serveWS)
	mux.Handle("/", http.FileServer(http.Dir("./web")))
	server := &http.Server{Addr: "127.0.0.1:8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
	log.Printf("Event Control Room available at http://127.0.0.1:8080")
	log.Fatal(server.ListenAndServe())
}

How does the bounded hub work?

  • The hub receives client changes through register and unregister.
  • The broadcast channel carries one encoded event to the hub.
  • Each client receives outbound data through a buffered send channel.
  • The default branch identifies a full queue without blocking the hub.
  • The slow client closes with websocket.StatusTryAgainLater while the hub continues its loop.
  • Save main.go.

Before you run the new server, do you expect a message from Alpha to remain local or reach both healthy tabs?

  • Start the bounded-hub server from the existing websocket-control-room directory by running this command:
go run .

What does this command do?

The command compiles the updated Go package. It starts the HTTP server with the hub running in its own goroutine.

The terminal reports that the Event Control Room is available at http://127.0.0.1:8080.

  • Switch back to the Alpha dashboard tab from earlier.
  • Refresh the Alpha tab.
  • Switch back to the Beta dashboard tab from earlier.
  • Refresh the Beta tab.
  • Return to the Alpha tab.
  • Enter hello from Alpha in the event message field.
  • Click Send event.

You now see hello from Alpha in both event feeds. The hub has turned two isolated connections into one broadcast group.

Server not starting?

Confirm that the earlier Go process stopped before you started the replacement. Two processes cannot listen on the same local address.

Compare every import and closing brace in main.go with the full-code tab. Help me diagnose why the bounded Go WebSocket hub does not compile or start.

Add the slow-client controls

The dashboard needs a repeatable way to create queue pressure. A query value marks one connection as slow while the burst control sends twenty events through the normal path.

  • In the code editor from earlier, select the entire contents of web/index.html.
  • Replace the selected markup with the dashboard file in the full-code tab below.

✔️ Awesome, I've got everything!

Great. Confirm that the controls include the burst button and the simulated slow-client link.

ⓧ 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>WebSocket Event Control Room</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <main class="shell">
    <header>
      <div><p class="eyebrow">LOCAL REAL-TIME LAB</p><h1>WebSocket Event Control Room</h1></div>
      <div class="telemetry"><span id="status" class="status connecting">connecting</span><span>Ping/Pong: <strong id="latency">waiting</strong></span></div>
    </header>
    <section class="panel controls">
      <label>Sender<input id="sender" value="operator" maxlength="32"></label>
      <form id="event-form"><label>Event message<input id="message" maxlength="256" placeholder="Deploy started" required></label><button type="submit">Send event</button></form>
      <button id="burst" type="button" class="secondary">Burst 20</button>
      <a href="/?slow=1" target="_blank" rel="noreferrer">Open a simulated slow client</a>
    </section>
    <section class="grid">
      <article class="panel"><h2>Broadcast events</h2><ol id="events" class="feed"></ol></article>
      <article class="panel"><h2>Connection log</h2><ol id="connection-log" class="feed compact"></ol></article>
    </section>
  </main>
  <script src="/app.js"></script>
</body>
</html>

What does this markup add?

  • The burst button gives the browser script a control for sending twenty events.
  • The /?slow=1 link opens a separate dashboard connection with the slow-client query value.
  • The connection log provides a visible destination for close metadata.
  • Save web/index.html.
  • Refresh the Alpha tab.

You see a Burst 20 button beside the simulated slow-client link.

  • In the code editor from earlier, select the entire contents of web/style.css.
  • Replace the selected styles with the responsive dashboard file in the full-code tab below.

✔️ Awesome, I've got everything!

Great. Confirm that the secondary burst button has its own purple treatment.

ⓧ I'd like to double check the full code

:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #07111f; color: #e7eef8; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top right, #12365a 0, transparent 35%), #07111f; }
.shell { width: min(1050px, 92vw); margin: 0 auto; padding: 48px 0; }
header, .controls, .telemetry, #event-form { display: flex; align-items: end; gap: 16px; }
header { justify-content: space-between; align-items: center; margin-bottom: 24px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 3.5rem); }
h2 { font-size: 1rem; letter-spacing: 0.08em; text-transform: uppercase; }
.eyebrow { margin-bottom: 8px; color: #65d9ff; font-size: 0.75rem; letter-spacing: 0.18em; }
.panel { border: 1px solid #28445f; border-radius: 16px; background: rgb(10 27 45 / 88%); padding: 20px; box-shadow: 0 18px 60px rgb(0 0 0 / 24%); }
.controls { flex-wrap: wrap; margin-bottom: 20px; }
label { display: grid; gap: 7px; color: #a9bdd1; font-size: 0.8rem; }
input, button { min-height: 42px; border-radius: 9px; border: 1px solid #355673; font: inherit; }
input { min-width: 210px; padding: 0 12px; background: #071421; color: #f5f9ff; }
button { padding: 0 16px; cursor: pointer; border-color: #4cc9f0; background: #168aad; color: white; font-weight: 700; }
button.secondary { border-color: #7667d8; background: #4338a5; }
a { color: #79dcff; }
.grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 20px; }
.telemetry { align-items: center; color: #a9bdd1; font-size: 0.85rem; }
.status { display: inline-block; border-radius: 999px; padding: 6px 10px; background: #3d4650; color: white; font-weight: 700; }
.status.open { background: #147d64; }
.status.closed { background: #9d3345; }
.feed { min-height: 230px; max-height: 430px; overflow: auto; margin: 0; padding-left: 22px; }
.feed li { padding: 9px 4px; border-bottom: 1px solid #213a51; overflow-wrap: anywhere; }
.feed.compact { color: #a9bdd1; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.78rem; }
@media (max-width: 760px) { header, .grid { display: grid; grid-template-columns: 1fr; } .telemetry { justify-content: start; } }

What do these styles control?

  • The panel rules separate controls from the two event feeds.
  • The status classes make open and closed connections visually distinct.
  • The responsive rule stacks the dashboard on narrower screens.
  • Save web/style.css.
  • Refresh the Alpha tab.

The burst control now has a purple background. The connection status uses a colored pill.

  • In the code editor from earlier, select the entire contents of web/app.js.
  • Replace the selected script with the broadcast client in the full-code tab below.

✔️ Awesome, I've got everything!

Great. Confirm that connect() preserves the page query in the WebSocket URL.

ⓧ I'd like to double check the full code

const statusElement = document.querySelector("#status");
const latencyElement = document.querySelector("#latency");
const senderElement = document.querySelector("#sender");
const messageElement = document.querySelector("#message");
const formElement = document.querySelector("#event-form");
const burstElement = document.querySelector("#burst");
const eventsElement = document.querySelector("#events");
const connectionLogElement = document.querySelector("#connection-log");
let socket = null;

function appendConnectionLog(message) {
  const item = document.createElement("li");
  item.textContent = `${new Date().toLocaleTimeString()} ${message}`;
  connectionLogElement.prepend(item);
}

function setStatus(value) {
  statusElement.textContent = value;
  statusElement.className = `status ${value}`;
}

function renderEvent(event) {
  const item = document.createElement("li");
  const sender = event.sender ? `${event.sender}: ` : "";
  item.textContent = `${event.sentAt || "now"} ${sender}${event.message || event.type}`;
  eventsElement.prepend(item);
}

function connect() {
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
  const url = `${protocol}//${window.location.host}/ws${window.location.search}`;
  const nextSocket = new WebSocket(url);
  socket = nextSocket;
  window.controlSocket = nextSocket;
  setStatus("connecting");
  appendConnectionLog(`opening ${url}`);

  nextSocket.addEventListener("open", () => {
    if (socket !== nextSocket) return;
    setStatus("open");
    appendConnectionLog("connection open");
  });
  nextSocket.addEventListener("message", (messageEvent) => {
    try { renderEvent(JSON.parse(messageEvent.data)); }
    catch { appendConnectionLog("received invalid JSON from server"); }
  });
  nextSocket.addEventListener("error", () => appendConnectionLog("connection error"));
  nextSocket.addEventListener("close", (closeEvent) => {
    if (socket !== nextSocket) return;
    setStatus("closed");
    latencyElement.textContent = "waiting";
    appendConnectionLog(`closed code=${closeEvent.code} clean=${closeEvent.wasClean} reason=${closeEvent.reason || "none"}`);
  });
}

function sendEvent(message) {
  if (!socket || socket.readyState !== WebSocket.OPEN) {
    appendConnectionLog("send rejected because the socket is not open");
    return;
  }
  socket.send(JSON.stringify({ sender: senderElement.value.trim() || "anonymous", message }));
}

formElement.addEventListener("submit", (submitEvent) => {
  submitEvent.preventDefault();
  const message = messageElement.value.trim();
  if (message) {
    sendEvent(message);
    messageElement.value = "";
    messageElement.focus();
  }
});

burstElement.addEventListener("click", () => {
  for (let index = 1; index <= 20; index += 1) sendEvent(`burst-${index}`);
});

connect();

How does the browser drive the test?

  • The connection URL appends window.location.search so the server receives ?slow=1 from the delayed tab.
  • The burst handler calls sendEvent() twenty times.
  • The close handler records code and reason in the connection log.
  • The window.controlSocket reference exposes the active connection in browser DevTools.
  • Save web/app.js.
  • Refresh the Alpha tab.

The status changes to open. The connection log records the opening URL and the successful connection.

Controls not responding?

Confirm that web/index.html contains an element with id="burst". The selector in web/app.js depends on that exact identifier.

Refresh the page after saving all three web files. Help me diagnose why the Event Control Room burst control is not responding.

Prove failure isolation

The delayed writer sleeps before each outbound message. A burst fills its eight-message queue faster than that writer can drain it.

The full queue becomes a local failure boundary. Healthy clients keep their independent writer pumps.

  • Enter Alpha in the Alpha tab's Sender field.
  • Switch back to the Beta tab.
  • Refresh the Beta tab.
  • Enter Beta in the Beta tab's Sender field.
  • Return to the Alpha tab.
  • Click Open a simulated slow client.
  • Enter Slow in the new tab's Sender field.

Before you trigger the burst, which connection do you expect the full queue to remove?

  • Return to the Alpha tab.
  • Click Burst 20.

Alpha and Beta continue receiving the burst events. The slow tab changes to closed.

The slow tab's connection log reports code 1013 with reason client is too slow. You have isolated the overloaded client without stalling the healthy connections.

What did the queue prove?

The non-blocking send attempts delivery only while queue capacity remains. A full queue identifies the client whose writer cannot keep pace.

Code 1013 tells that client to try again later. Every healthy queue continues accepting the broadcast.

Slow tab staying open?

Confirm that the slow tab's address includes ?slow=1. The server only applies slowClientDelay when that query value is present.

Trigger the burst from a healthy tab while all three statuses show open. Help me diagnose why the bounded queue is not closing my simulated slow WebSocket client.

Your hub now broadcasts without sharing one client's delay across the room. Next, you will add heartbeat latency and recovery after an interrupted connection.

Add Heartbeats and Recovery

Your bounded WebSocket hub now protects healthy clients when one connection falls behind. Every active connection also has a dedicated reader pump plus a dedicated writer pump.

However, a connection that opened successfully can later become half-dead. The dashboard needs a way to detect that failure plus a controlled path back online.

Timed Ping/Pong checks measure whether the peer still responds. Reconnect backoff prevents the browser from hammering an unavailable server.

In this step, get ready to:
  • Add timed Ping/Pong checks to each server connection.
  • Reject browser sends when the local buffer crosses its ceiling.
  • Recover the dashboard after server or page lifecycle interruptions.
Measure connection liveness

The Go server needs explicit limits for incoming data plus timed network operations. A latency field carries the measured Ping/Pong round trip to the dashboard.

  • Return to main.go in your code editor.
  • Replace the existing constant block with this final set of server policies:
const (
	sendQueueSize     = 8
	maxMessageBytes   = int64(1024)
	heartbeatInterval = 5 * time.Second
	operationTimeout  = 2 * time.Second
	slowClientDelay   = 500 * time.Millisecond
)

What do these policies control?

  • The existing sendQueueSize keeps each client queue bounded at eight messages.
  • The maxMessageBytes value limits each incoming message to 1 KiB.
  • The heartbeatInterval value starts a liveness check every five seconds.
  • The operationTimeout value prevents a network operation from waiting indefinitely.
  • Find the existing outboundEvent structure.
  • Replace that structure with this version:
type outboundEvent struct {
	Type      string `json:"type"`
	Sender    string `json:"sender,omitempty"`
	Message   string `json:"message,omitempty"`
	SentAt    string `json:"sentAt,omitempty"`
	LatencyMS int64  `json:"latencyMs,omitempty"`
}

What does the latency field carry?

The LatencyMS field carries the measured round-trip duration in milliseconds. The omitempty tag leaves it out of ordinary event messages.

  • Save main.go.
  • Switch back to the server terminal from earlier.
  • Stop the current process by pressing Ctrl+C.
  • Start the updated server by running this command:
go run .

What does this run prove?

The command recompiles the server with the new fields plus timing policies. You should see the Event Control Room address in the terminal.

The server compiles with the new policies. The next change applies those policies to each connection.

A read limit protects the server before application validation begins. The reader pump then accepts only text messages containing valid JSON within the sender plus message limits.

  • Find defer conn.CloseNow() inside serveWS.
  • Add the read limit directly below that line so the two lines match this reference:
defer conn.CloseNow()
conn.SetReadLimit(maxMessageBytes)

What does the read limit protect?

The connection stops accepting a message after it reaches maxMessageBytes. This places a protocol-level boundary before the server allocates more space for the payload.

  • Find the read loop inside readPump.
  • Replace the contents of that loop with this validation flow:
messageType, payload, err := c.conn.Read(ctx)
if err != nil {
	return err
}
if messageType != websocket.MessageText {
	return c.conn.Close(websocket.StatusUnsupportedData, "text messages only")
}
var incoming inboundEvent
if err := json.Unmarshal(payload, &incoming); err != nil {
	return c.conn.Close(websocket.StatusPolicyViolation, "invalid JSON event")
}
incoming.Sender = strings.TrimSpace(incoming.Sender)
incoming.Message = strings.TrimSpace(incoming.Message)
if incoming.Sender == "" || len(incoming.Sender) > 32 {
	return c.conn.Close(websocket.StatusPolicyViolation, "sender must be 1 to 32 bytes")
}
if incoming.Message == "" || len(incoming.Message) > 256 {
	return c.conn.Close(websocket.StatusPolicyViolation, "message must be 1 to 256 bytes")
}
outgoing, err := json.Marshal(outboundEvent{Type: "event", Sender: incoming.Sender, Message: incoming.Message, SentAt: time.Now().UTC().Format(time.RFC3339)})
if err != nil {
	return err
}
select {
case h.broadcast <- outgoing:
case <-ctx.Done():
	return ctx.Err()
}

How does the reader enforce the contract?

  • The type check rejects binary input with close code 1003.
  • The JSON check rejects malformed application events with close code 1008.
  • The sender validation requires between 1 byte plus 32 bytes.
  • The message validation requires between 1 byte plus 256 bytes.
  • Save main.go.
  • Stop the running server by pressing Ctrl+C.
  • Start the validated server by running this command:
go run .

What does this restart apply?

The new process applies the read limit plus the text-only event contract to every new connection. The terminal should print the local dashboard address without a compilation error.

  • Refresh one healthy dashboard tab.
  • Enter heartbeat setup in the event message field.
  • Click Send event.

You should see the validated event in the broadcast feed. The reader pump remains active after processing it.

The Ping operation waits for the matching Pong response. The active reader pump is what allows that control frame to be processed.

  • Find the opening line of writePump.
  • Insert these ticker lines immediately inside the function:
heartbeat := time.NewTicker(heartbeatInterval)
defer heartbeat.Stop()

Why use a ticker?

The ticker supplies a heartbeat signal at the configured interval. Stopping it when the writer exits releases its timing resource.

  • Find the case <-ctx.Done(): branch inside the writer pump.
  • Insert this heartbeat branch immediately above it:
case <-heartbeat.C:
	started := time.Now()
	pingContext, cancelPing := context.WithTimeout(ctx, operationTimeout)
	err := c.conn.Ping(pingContext)
	cancelPing()
	if err != nil {
		return err
	}
	latency, err := json.Marshal(outboundEvent{Type: "latency", LatencyMS: time.Since(started).Milliseconds()})
	if err != nil {
		return err
	}
	if err := writeText(ctx, c.conn, latency); err != nil {
		return err
	}

How does the heartbeat measure latency?

  • The writer records the start time before sending Ping.
  • The timeout limits how long the server waits for Pong.
  • The elapsed duration becomes a latency event for the dashboard.
  • Save main.go.
  • Stop the current server by pressing Ctrl+C.
  • Start the heartbeat server by running this command:
go run .

What should the server do now?

The process starts one ticker inside each client writer pump. Each ticker begins checking its connection every five seconds.

  • Refresh the healthy dashboard tab.
  • Wait for the first heartbeat interval.

The first heartbeat can take about five seconds to arrive. You should see latency data reach the dashboard while the connection stays open.

  • Find the existing writeText function.
  • Replace it with this timeout-aware version:
func writeText(ctx context.Context, conn *websocket.Conn, payload []byte) error {
	writeContext, cancelWrite := context.WithTimeout(ctx, operationTimeout)
	defer cancelWrite()
	return conn.Write(writeContext, websocket.MessageText, payload)
}

Why time out every text write?

A blocked write can otherwise hold the writer pump indefinitely. The derived context gives each text message the same operation deadline as Ping.

  • Save main.go.
  • Stop the current server by pressing Ctrl+C.
  • Start the completed server by running this command:
go run .

What does the completed server provide?

Every connection now has one active reader plus one timeout-aware writer. The writer sends application events plus measured heartbeat events.

  • Refresh the healthy dashboard tab.
  • Send one event from the dashboard.

You should see the event return through the broadcast feed. That confirms timed writes did not interrupt normal delivery.

Server not compiling?

Confirm that heartbeatInterval plus operationTimeout remain inside the constant block. Check that the heartbeat branch sits inside the writer pump's select statement.

Compare each closing brace with the reference below. Help me diagnose the heartbeat server changes.

✔️ Awesome, I've got everything!

Great. Double-check that you saved main.go while the server was stopped.

ⓧ I'd like to double check the full code

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"strings"
	"time"

	"github.com/coder/websocket"
)

const (
	sendQueueSize     = 8
	maxMessageBytes   = int64(1024)
	heartbeatInterval = 5 * time.Second
	operationTimeout  = 2 * time.Second
	slowClientDelay   = 500 * time.Millisecond
)

type inboundEvent struct {
	Sender  string `json:"sender"`
	Message string `json:"message"`
}

type outboundEvent struct {
	Type      string `json:"type"`
	Sender    string `json:"sender,omitempty"`
	Message   string `json:"message,omitempty"`
	SentAt    string `json:"sentAt,omitempty"`
	LatencyMS int64  `json:"latencyMs,omitempty"`
}

type client struct {
	conn  *websocket.Conn
	send  chan []byte
	delay time.Duration
}

type hub struct {
	register   chan *client
	unregister chan *client
	broadcast  chan []byte
}

func newHub() *hub {
	return &hub{register: make(chan *client), unregister: make(chan *client), broadcast: make(chan []byte)}
}

func (h *hub) run() {
	clients := make(map[*client]struct{})
	for {
		select {
		case c := <-h.register:
			clients[c] = struct{}{}
		case c := <-h.unregister:
			if _, exists := clients[c]; exists {
				delete(clients, c)
				close(c.send)
			}
		case message := <-h.broadcast:
			for c := range clients {
				select {
				case c.send <- message:
				default:
					delete(clients, c)
					go func(slow *client) {
						_ = slow.conn.Close(websocket.StatusTryAgainLater, "client is too slow")
						close(slow.send)
					}(c)
				}
			}
		}
	}
}

func (h *hub) serveWS(w http.ResponseWriter, r *http.Request) {
	conn, err := websocket.Accept(w, r, nil)
	if err != nil {
		log.Printf("WebSocket handshake rejected: %v", err)
		return
	}
	defer conn.CloseNow()
	conn.SetReadLimit(maxMessageBytes)

	c := &client{conn: conn, send: make(chan []byte, sendQueueSize)}
	if r.URL.Query().Get("slow") == "1" {
		c.delay = slowClientDelay
	}
	welcome, err := json.Marshal(outboundEvent{Type: "system", Message: "connected", SentAt: time.Now().UTC().Format(time.RFC3339)})
	if err != nil {
		log.Printf("Could not encode welcome event: %v", err)
		return
	}
	c.send <- welcome
	h.register <- c

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	errors := make(chan error, 2)
	go func() { errors <- h.readPump(ctx, c) }()
	go func() { errors <- h.writePump(ctx, c) }()
	firstError := <-errors
	cancel()
	h.unregister <- c
	log.Printf("WebSocket session ended: %v", firstError)
}

func (h *hub) readPump(ctx context.Context, c *client) error {
	for {
		messageType, payload, err := c.conn.Read(ctx)
		if err != nil {
			return err
		}
		if messageType != websocket.MessageText {
			return c.conn.Close(websocket.StatusUnsupportedData, "text messages only")
		}
		var incoming inboundEvent
		if err := json.Unmarshal(payload, &incoming); err != nil {
			return c.conn.Close(websocket.StatusPolicyViolation, "invalid JSON event")
		}
		incoming.Sender = strings.TrimSpace(incoming.Sender)
		incoming.Message = strings.TrimSpace(incoming.Message)
		if incoming.Sender == "" || len(incoming.Sender) > 32 {
			return c.conn.Close(websocket.StatusPolicyViolation, "sender must be 1 to 32 bytes")
		}
		if incoming.Message == "" || len(incoming.Message) > 256 {
			return c.conn.Close(websocket.StatusPolicyViolation, "message must be 1 to 256 bytes")
		}
		outgoing, err := json.Marshal(outboundEvent{Type: "event", Sender: incoming.Sender, Message: incoming.Message, SentAt: time.Now().UTC().Format(time.RFC3339)})
		if err != nil {
			return err
		}
		select {
		case h.broadcast <- outgoing:
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func (h *hub) writePump(ctx context.Context, c *client) error {
	heartbeat := time.NewTicker(heartbeatInterval)
	defer heartbeat.Stop()
	for {
		select {
		case message, open := <-c.send:
			if !open {
				return nil
			}
			if c.delay > 0 {
				select {
				case <-time.After(c.delay):
				case <-ctx.Done():
					return ctx.Err()
				}
			}
			if err := writeText(ctx, c.conn, message); err != nil {
				return err
			}
		case <-heartbeat.C:
			started := time.Now()
			pingContext, cancelPing := context.WithTimeout(ctx, operationTimeout)
			err := c.conn.Ping(pingContext)
			cancelPing()
			if err != nil {
				return err
			}
			latency, err := json.Marshal(outboundEvent{Type: "latency", LatencyMS: time.Since(started).Milliseconds()})
			if err != nil {
				return err
			}
			if err := writeText(ctx, c.conn, latency); err != nil {
				return err
			}
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func writeText(ctx context.Context, conn *websocket.Conn, payload []byte) error {
	writeContext, cancelWrite := context.WithTimeout(ctx, operationTimeout)
	defer cancelWrite()
	return conn.Write(writeContext, websocket.MessageText, payload)
}

func main() {
	h := newHub()
	go h.run()
	mux := http.NewServeMux()
	mux.HandleFunc("/ws", h.serveWS)
	mux.Handle("/", http.FileServer(http.Dir("./web")))
	server := &http.Server{Addr: "127.0.0.1:8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
	log.Printf("Event Control Room available at http://127.0.0.1:8080")
	log.Fatal(server.ListenAndServe())
}
Guard browser sends

The browser has its own outbound queue. Checking connection state plus bufferedAmount creates a local boundary before a send can add more pressure.

  • Return to web/app.js in your code editor.
  • Replace the declarations at the top of the file with this final state:
const statusElement = document.querySelector("#status");
const latencyElement = document.querySelector("#latency");
const senderElement = document.querySelector("#sender");
const messageElement = document.querySelector("#message");
const formElement = document.querySelector("#event-form");
const burstElement = document.querySelector("#burst");
const eventsElement = document.querySelector("#events");
const connectionLogElement = document.querySelector("#connection-log");
const maxBufferedBytes = 64 * 1024;
let socket = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
let pageClosing = false;

What state does the browser track?

  • The latencyElement reference targets the Ping/Pong telemetry value.
  • The maxBufferedBytes constant sets a local send ceiling of 64 KiB.
  • The reconnect variables track the pending timer plus the current attempt count.
  • The pageClosing flag distinguishes deliberate page exit from a recoverable interruption.
  • Find the existing renderEvent function.
  • Replace it with this latency-aware version:
function renderEvent(event) {
  if (event.type === "latency") {
    latencyElement.textContent = `${event.latencyMs} ms`;
    return;
  }
  const item = document.createElement("li");
  const sender = event.sender ? `${event.sender}: ` : "";
  item.textContent = `${event.sentAt || "now"} ${sender}${event.message || event.type}`;
  eventsElement.prepend(item);
}

How does latency reach the display?

Latency events update the telemetry value before the function returns. Ordinary system events plus broadcast events continue into the feed.

  • Save web/app.js.
  • Refresh the healthy dashboard tab.
  • Wait for the next heartbeat interval.

You should see a millisecond value replace waiting beside Ping/Pong. That value proves the reader processed Pong before the writer reported the round trip.

  • Find the existing sendEvent function.
  • Replace it with this guarded version:
function sendEvent(message) {
  if (!socket || socket.readyState !== WebSocket.OPEN) {
    appendConnectionLog("send rejected because the socket is not open");
    return;
  }
  if (socket.bufferedAmount > maxBufferedBytes) {
    appendConnectionLog(`send rejected at bufferedAmount=${socket.bufferedAmount}`);
    return;
  }
  socket.send(JSON.stringify({ sender: senderElement.value.trim() || "anonymous", message }));
}

How do the send guards work?

  • The first guard accepts sends only while the socket state equals WebSocket.OPEN.
  • The second guard rejects a send when queued bytes exceed the local ceiling.
  • Successful sends preserve the same sender plus message payload.
  • Save web/app.js.
  • Refresh the healthy dashboard tab.
  • Enter send guard works in the event message field.
  • Click Send event.

You should see the event in the broadcast feed. The open connection passes both local send checks.

Latency still shows waiting?

Confirm the updated Go server is still running. Check that renderEvent compares the event type with latency.

Refresh the page after saving web/app.js. Help me trace a missing Ping/Pong latency value.

Recover from interruptions

Immediate reconnect loops can flood a server that is already unavailable. Capped exponential delay spaces out attempts while jitter prevents every client from retrying at the same instant.

  • Add this scheduleReconnect function above connect in web/app.js.
function scheduleReconnect() {
  if (pageClosing || reconnectTimer !== null) return;
  const baseDelay = Math.min(1000 * (2 ** reconnectAttempts), 10000);
  const jitter = Math.floor(Math.random() * 250);
  const delay = baseDelay + jitter;
  reconnectAttempts += 1;
  appendConnectionLog(`reconnect scheduled in ${delay} ms`);
  reconnectTimer = window.setTimeout(() => { reconnectTimer = null; connect(); }, delay);
}

How does reconnect backoff behave?

  • The base delay doubles after each failed attempt.
  • The cap prevents the base delay from growing beyond ten seconds.
  • The random jitter adds up to 249 milliseconds.
  • The timer guard prevents duplicate reconnect schedules.
  • Find the existing connect function.
  • Replace it with this lifecycle-aware version:
function connect() {
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
  const url = `${protocol}//${window.location.host}/ws${window.location.search}`;
  const nextSocket = new WebSocket(url);
  socket = nextSocket;
  window.controlSocket = nextSocket;
  setStatus("connecting");
  appendConnectionLog(`opening ${url}`);

  nextSocket.addEventListener("open", () => {
    if (socket !== nextSocket) return;
    reconnectAttempts = 0;
    setStatus("open");
    appendConnectionLog("connection open");
  });
  nextSocket.addEventListener("message", (messageEvent) => {
    try { renderEvent(JSON.parse(messageEvent.data)); }
    catch { appendConnectionLog("received invalid JSON from server"); }
  });
  nextSocket.addEventListener("error", () => appendConnectionLog("connection error"));
  nextSocket.addEventListener("close", (closeEvent) => {
    if (socket !== nextSocket) return;
    setStatus("closed");
    latencyElement.textContent = "waiting";
    appendConnectionLog(`closed code=${closeEvent.code} clean=${closeEvent.wasClean} reason=${closeEvent.reason || "none"}`);
    scheduleReconnect();
  });
}

What does the connection lifecycle record?

  • The active socket is exposed as window.controlSocket for protocol testing.
  • A successful connection resets the retry counter.
  • A close event records code plus wasClean plus reason.
  • An unexpected closure schedules the next connection attempt.
  • Save web/app.js.
  • Refresh the dashboard tab.

You should see an opening entry followed by connection open in the connection log. The status badge should show open.

Page lifecycle handling closes a connection when the document leaves the active page. A restored page from the browser cache then creates a fresh connection.

  • Add this pagehide handler below the form plus burst event handlers:
window.addEventListener("pagehide", () => {
  pageClosing = true;
  window.clearTimeout(reconnectTimer);
  reconnectTimer = null;
  if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000, "page hidden");
});

What happens when the page hides?

The handler marks the exit as intentional. It clears pending retries before initiating a normal WebSocket close with code 1000.

  • Add this pageshow handler directly below the pagehide handler:
window.addEventListener("pageshow", (pageEvent) => {
  if (pageEvent.persisted) {
    pageClosing = false;
    connect();
  }
});

What happens when a cached page returns?

A persisted page may return with its previous JavaScript state. The handler clears the closing flag before creating a fresh connection.

  • Save web/app.js.
  • Refresh the dashboard tab.

You should see the status return to open. The latency value should update after the next heartbeat.

✔️ Awesome, I've got everything!

Great. Double-check that you saved web/app.js before testing recovery.

ⓧ I'd like to double check the full code

const statusElement = document.querySelector("#status");
const latencyElement = document.querySelector("#latency");
const senderElement = document.querySelector("#sender");
const messageElement = document.querySelector("#message");
const formElement = document.querySelector("#event-form");
const burstElement = document.querySelector("#burst");
const eventsElement = document.querySelector("#events");
const connectionLogElement = document.querySelector("#connection-log");
const maxBufferedBytes = 64 * 1024;
let socket = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
let pageClosing = false;

function appendConnectionLog(message) {
  const item = document.createElement("li");
  item.textContent = `${new Date().toLocaleTimeString()} ${message}`;
  connectionLogElement.prepend(item);
}

function setStatus(value) {
  statusElement.textContent = value;
  statusElement.className = `status ${value}`;
}

function renderEvent(event) {
  if (event.type === "latency") {
    latencyElement.textContent = `${event.latencyMs} ms`;
    return;
  }
  const item = document.createElement("li");
  const sender = event.sender ? `${event.sender}: ` : "";
  item.textContent = `${event.sentAt || "now"} ${sender}${event.message || event.type}`;
  eventsElement.prepend(item);
}

function scheduleReconnect() {
  if (pageClosing || reconnectTimer !== null) return;
  const baseDelay = Math.min(1000 * (2 ** reconnectAttempts), 10000);
  const jitter = Math.floor(Math.random() * 250);
  const delay = baseDelay + jitter;
  reconnectAttempts += 1;
  appendConnectionLog(`reconnect scheduled in ${delay} ms`);
  reconnectTimer = window.setTimeout(() => { reconnectTimer = null; connect(); }, delay);
}

function connect() {
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
  const url = `${protocol}//${window.location.host}/ws${window.location.search}`;
  const nextSocket = new WebSocket(url);
  socket = nextSocket;
  window.controlSocket = nextSocket;
  setStatus("connecting");
  appendConnectionLog(`opening ${url}`);

  nextSocket.addEventListener("open", () => {
    if (socket !== nextSocket) return;
    reconnectAttempts = 0;
    setStatus("open");
    appendConnectionLog("connection open");
  });
  nextSocket.addEventListener("message", (messageEvent) => {
    try { renderEvent(JSON.parse(messageEvent.data)); }
    catch { appendConnectionLog("received invalid JSON from server"); }
  });
  nextSocket.addEventListener("error", () => appendConnectionLog("connection error"));
  nextSocket.addEventListener("close", (closeEvent) => {
    if (socket !== nextSocket) return;
    setStatus("closed");
    latencyElement.textContent = "waiting";
    appendConnectionLog(`closed code=${closeEvent.code} clean=${closeEvent.wasClean} reason=${closeEvent.reason || "none"}`);
    scheduleReconnect();
  });
}

function sendEvent(message) {
  if (!socket || socket.readyState !== WebSocket.OPEN) {
    appendConnectionLog("send rejected because the socket is not open");
    return;
  }
  if (socket.bufferedAmount > maxBufferedBytes) {
    appendConnectionLog(`send rejected at bufferedAmount=${socket.bufferedAmount}`);
    return;
  }
  socket.send(JSON.stringify({ sender: senderElement.value.trim() || "anonymous", message }));
}

formElement.addEventListener("submit", (submitEvent) => {
  submitEvent.preventDefault();
  const message = messageElement.value.trim();
  if (message) {
    sendEvent(message);
    messageElement.value = "";
    messageElement.focus();
  }
});

burstElement.addEventListener("click", () => {
  for (let index = 1; index <= 20; index += 1) sendEvent(`burst-${index}`);
});

window.addEventListener("pagehide", () => {
  pageClosing = true;
  window.clearTimeout(reconnectTimer);
  reconnectTimer = null;
  if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000, "page hidden");
});

window.addEventListener("pageshow", (pageEvent) => {
  if (pageEvent.persisted) {
    pageClosing = false;
    connect();
  }
});

connect();

Stopping this local process is safe because your source files stay intact. This interruption gives the recovery policy a real failure to handle.

  • Wait until the Ping/Pong display shows a millisecond value.

Before the interruption, picture whether the browser gives up or keeps scheduling another attempt.

  • Switch back to the server terminal.
  • Stop the Go process by pressing Ctrl+C.

The dashboard status should change to closed. Its connection log should record closure metadata plus increasingly delayed reconnect attempts.

  • Restart the Go server by running this command:
go run .

What should recovery look like?

The next scheduled attempt reaches the restarted server. A successful open resets the retry count before heartbeat latency resumes.

  • Wait for the dashboard status to return to open.
  • Wait for the Ping/Pong latency value to update.
  • Enter recovery confirmed in the event message field.
  • Click Send event.

You should see the new event in the broadcast feed after reconnection. The same dashboard recovered without a manual refresh.

Dashboard not reconnecting?

Confirm that the restarted server is listening at http://127.0.0.1:8080. Check the connection log for a scheduled retry that has not fired yet.

Verify that scheduleReconnect() remains inside the socket's close handler. Help me diagnose the reconnect loop.

You have closed the lifecycle gap. The dashboard now measures liveness plus records closure plus returns after an interruption.

Secret mission

Attack the Protocol Boundary

Send binary data through the live dashboard to test the server's text-only contract. Then launch a foreign-origin handshake to prove the same-origin defense stops it before upgrade.

Clean Up Your Resources

Clean Up Your Resources

Your local resources can stay available, pause for later, or be deleted entirely. This project uses only your computer, so there are no ongoing service costs.

Resources you used:

  • A local Go module directory named websocket-control-room, containing go.mod, go.sum, main.go, plus the three files inside web.
  • A local Go WebSocket server process serving the dashboard at http://127.0.0.1:8080.
  • A temporary Python HTTP server process serving the foreign-origin test page at http://127.0.0.1:8000.

Keep everything running

No action is needed while you are actively testing the control room. Both servers run locally without creating an ongoing service bill.

  • Keep the websocket-control-room directory for future experiments.
  • Continue running the Go server while testing the dashboard at http://127.0.0.1:8080.
  • Continue running the Python HTTP server while repeating the foreign-origin test at http://127.0.0.1:8000.

The downloaded dependency remains in the shared Go module cache for other projects.

Pause - I'll come back to this later

Shut down the local processes to free their ports while preserving your source files. You can restart the project from the same directory later.

  • Switch back to the terminal running the Go server.
  • Press Ctrl+C to stop the Go server.
  • Switch back to the terminal running the Python HTTP server.
  • Press Ctrl+C to stop the Python HTTP server.
  • Close the dashboard browser tabs to stop their reconnect attempts.
  • Close the foreign-origin test page.

The two local server processes are now stopped. Your source files and shared Go module cache remain available.

Delete - I don't want to use this again

Removing the project directory is permanent, so confirm that you no longer need your source files or incident note. The commands below leave the shared Go module cache untouched.

  • Press Ctrl+C in the Go server terminal if the process is still running.
  • Press Ctrl+C in the Python HTTP server terminal if the process is still running.
  • Close the dashboard browser tabs.
  • Close the foreign-origin test page.
  • Remove only the websocket-control-room directory by running these commands in the Go server terminal:
cd ..
rm -rf websocket-control-room
ls

What Does This Cleanup Remove?

  • The cd .. command leaves the project directory so the shell can remove it.
  • The rm -rf websocket-control-room command deletes only the local project directory.
  • The ls command lists the remaining items in the parent directory.

You should not see websocket-control-room in the directory listing. That confirms the project files have been removed.

Still See the Project Directory?

  • Confirm that the terminal moved to the directory containing websocket-control-room before the removal command ran.
  • Check whether another process or editor still has a file inside the directory open.

Ask for help with the cleanup command: Help me safely remove my local websocket-control-room directory on Linux.

Nice Work!

Nice Work!

You did it! Your local WebSocket Event Control Room now broadcasts live JSON events across browser tabs.

You've learned how to:

  • Connect browser clients to a Go server over WebSocket. Inspect the HTTP Upgrade response with status 101 Switching Protocols.
  • Replace connection-local echo with a hub that owns fan-out state. Use bounded queues to isolate a slow client. Close only that client with code 1013.
  • Measure latency with Ping/Pong control frames. Log close metadata for each interrupted connection. Reconnect with capped exponential backoff.
  • Secret Mission: Reject unsupported binary messages with close code 1003. Block a foreign origin with HTTP 403 before the WebSocket upgrade.

Ready to quiz yourself?