Bonus — What a WebSocket Actually Is (the pipe under every broadcast)

Bonus — What a WebSocket Actually Is (the pipe under every broadcast)

A bridge bonus tutorial for the JS-naive Rails builder: what a WebSocket actually is, taught as a web-platform capability independent of Rails. Derives the one wall plain HTTP can never climb — the server can never speak first — shows the polling version you'd build without it, then explains the WebSocket as a persistent two-way pipe born from an ordinary HTTP request that refuses to hang up (the Upgrade / 101 Switching Protocols handshake). Shows the raw browser WebSocket primitive (explicitly background) so the capability is demystified, then connects it straight back to Campfire: Action Cable is Rails' wrapper around that one pipe (connection.rb), turbo_stream_from opens a subscription on it, and broadcast_append_to pushes HTML down it. The missing prerequisite under F4 (Turbo: Frames & Streams) and F4b (Turbo 8: Morphing).

a WebSocket is a persistent two-way connection between browser and server plain HTTP is request/response: the server can never speak first polling is the naive round-trip you build without a socket the WebSocket handshake is an ordinary HTTP GET that upgrades (101 Switching Protocols) and stays open ws:// and wss:// are the socket URL schemes the browser has a native WebSocket object — it is a web-platform capability, not a Rails feature Action Cable is Rails' wrapper around one authenticated WebSocket connection many channels are multiplexed over one socket turbo_stream_from opens a subscription on the socket; broadcast pushes HTML down it in-band (HTTP reply) vs out-of-band (socket push) carry the same HTML campfire by nityeshagarwal

Two tutorials ago, F4: Turbo — Frames & Streams kept making the same quiet promise: when one person hits Enter, the message appears on four other people's screens a half-second later. The phrase used was "pushed over a WebSocket." F4b: Turbo 8 — Morphing & Live Refresh leaned on it harder: a card moves on your board and "lands on the other three screens within a heartbeat." Both tutorials treated that word — WebSocket — as a given, a pipe that was simply there, and moved on to the beautiful Rails wrapped around it.

This is the tutorial that stops and looks at the pipe. And it's deliberately not a Rails lesson — it's a web-platform lesson, the kind of thing that's true whether you write Rails, Python, or Go. You asked the exact right question: what even is a WebSocket, in a browser? Once you see it, every "broadcast" line in those Turbo tutorials stops being magic and becomes plumbing you can picture.

Let's earn it from the one wall plain HTTP can never climb.

The wall: the server can never speak first

Here is the entire model of the web you already know, the one every Rails app you've built lives inside. The browser asks; the server answers; the conversation ends.

Browser:  GET /rooms/5          →
                                ←   200 OK, here's the HTML
                          (connection closes. silence.)

You type a URL, Rails hands back HTML, the connection closes. You submit a form, Rails hands back a redirect, the connection closes. Every single exchange is the browser starting a sentence and the server finishing it. This is request/response, and it is the deepest assumption in all of Rails — def show only ever runs because someone asked for show.

Now sit with the chat problem for a second. Nobody on the receiving end asked for anything. Four people have the room open, their browsers are idle, no form was submitted — and a new message has to land on their screens anyway. The event didn't originate with them. It originated with someone else, on the server's side of the wire.

Request/response has no move for this. The server is holding a message it desperately wants to hand to four idle browsers, and it has no way to start the sentence. It can only ever answer. A browser that isn't asking gets nothing. That's the wall: over plain HTTP, the server can never speak first.

A two-panel technical diagram on an off-white (#FAF8F3) background, generous ev…

The naive version: keep asking ("are we there yet?")

You don't know JavaScript, so let's stay honest about what you'd actually build to climb this wall with only the tools you have. If the server can't tell you when something's new, then the browser has to keep asking — on a timer. This is called polling, and it's the round-trip you'd reach for first.

In the most Rails-native form, you wouldn't even write a loop — you'd drop one line in the layout and let the browser re-fetch the whole page every few seconds:

<%# the naive "live chat": brute-force re-ask, forever %>
<meta http-equiv="refresh" content="3">

Or, a notch more surgical, a fragment that re-requests itself on a clock. Either way the shape is identical: the browser interrupts the server every few seconds to ask "anything new? anything new? anything new?" — like a kid in the backseat — and the server, almost every time, has to answer "no."

Count what that costs. With a 3-second poll, a message can sit invisible for up to 3 seconds — your "instant" chat has a built-in lag floor. Drop the interval to 300ms to feel snappy and now every open browser hammers your server ten times a second, and Rails boots a full request, checks auth, hits the database, and renders — just to say "nothing changed" — for every idle reader. A hundred people watching a quiet room is a hundred pointless requests a second. You're paying full request/response price for a conversation that's almost entirely silence.

The contrast: polling makes the browser responsible for discovering news it has no way to predict, so it either reacts late or asks constantly. Everything wrong here traces to one root: you're still inside request/response, and request/response simply has no channel for the server to volunteer information. You don't need a faster poll. You need a different kind of connection — one where the server can finally speak first.

The mental model: a phone line, not letters

Here's the whole idea in one swap of metaphor.

Request/response is letters. You mail a question, you get a letter back, the exchange is over. To learn anything new you mail another letter. There's no way for the other side to mail you unprompted — they don't have an envelope open.

A WebSocket is a phone call. You dial once, the line connects, and then it just stays open. Either side can talk whenever they have something to say, the instant they have it. No new dialing, no "are we there yet" — an open line, both directions, indefinitely. When the server gets a new message, it doesn't wait to be asked. It just speaks, and your browser hears it immediately, because the line was never hung up.

That's the entire capability: a WebSocket is a persistent, two-way connection between one browser and the server that stays open so either end can send data at any time. That's it. Everything else is detail.

And now the detail that demystifies it completely — because the natural next question is "if HTTP can't do this, how does the browser even open such a thing? Is it some separate secret protocol?"

"Where does this magic pipe come from — is it bypassing HTTP somehow?" No, and this is the lovely part: a WebSocket is born from an ordinary HTTP request that refuses to hang up. The browser sends what looks like a totally normal GET, but with one special header — Upgrade: websocket — which politely asks, "can we stop doing request/response on this connection and keep it open as a two-way pipe instead?" If the server agrees, it replies with a status code you've probably never seen — 101 Switching Protocols — instead of the usual 200. At that instant the TCP connection underneath stops behaving like HTTP and becomes the permanent open line. Same connection, upgraded mid-handshake. A WebSocket isn't a detour around the web; it's an HTTP request that, instead of ending, transforms.

A vertical sequence / timeline diagram on an off-white (#FAF8F3) background, ge…
A few plain facts to make it concrete, none of them Rails:

  • A normal page URL is http:// or https://. A socket URL has its own scheme: ws://, or wss:// when it's encrypted (the s, same as https, means TLS). When you see wss://, that's a WebSocket.
  • The line is full-duplex — a fancy word for "both directions at once," like a phone call where you can both talk, not a walkie-talkie where one waits for the other.
  • It stays open until someone hangs up — you close the tab, the network drops, or either side deliberately ends it. (And when the network blips, something has to redial — hold that thought; it matters in a second.)

A short history aside

Before WebSockets existed (the standard was finalized in 2011), people were desperate for the server to speak first, and they faked it with ingenious hacks collectively nicknamed "Comet" — most commonly long-polling: the browser sends a request and the server, instead of answering right away, just... holds it open, says nothing, and only responds at the moment news actually arrives — then the browser immediately opens another held-open request. It's a phone call simulated by never quite finishing a letter. It worked, it was awkward, and it's exactly the kind of widespread pain that gets a real solution standardized. WebSockets are the web platform finally saying "fine, here's a real open line."

The browser already has this built in

Here's the part that should fully dissolve the mystery: the WebSocket is a feature of the browser itself, sitting right next to things you already trust like fetch or localStorage. Every browser has shipped a WebSocket object since around 2011. It is not a library, not a framework, not a Rails thing — it's a native web-platform primitive.

I'm going to show you the raw browser API once, and then we're done with JavaScript for this tutorial — you will never write this in a Rails app, because Rails writes it for you. I'm showing it only so you can see with your own eyes that the "magic pipe" is four boring lines of a standard browser feature:

// This is the BROWSER's built-in WebSocket — not Rails, not a gem. Background only.
const socket = new WebSocket("wss://chat.example.com/cable")  // dial the line

socket.onmessage = (event) => {                                // the server SPOKE FIRST —
  console.log("server just pushed:", event.data)               // this fires with no request
}

socket.send("hello from the browser")                          // and you can talk back, anytime

Read onmessage slowly, because it's the whole point of the tutorial in one line. There is no request above it. Nobody asked. That function runs because the server decided to send something down the already-open line, and the browser fires onmessage the instant it arrives. That single callback is the wall finally coming down — the server, speaking first.

This is the bedrock. Everything Rails does on top is convenience over exactly this object.

The 37signals way: Action Cable is the wrapper, Turbo dials for you

Now walk back into Campfire with the pipe in your head, and watch every mysterious line resolve into something you can picture.

Rails' wrapper around the WebSocket is called Action Cable, and the first thing to internalize is that there is essentially one socket per open browser tab — one phone call — and everything rides over it. That single connection is a real class you can read (app/channels/application_cable/connection.rb:1-19):

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    include Authentication::SessionLookup

    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private
      def find_verified_user
        if verified_session = find_session_by_cookie
          verified_session.user
        else
          reject_unauthorized_connection
        end
      end
  end
end

This connect method runs once, at the moment the browser dials — the moment of that 101 Switching Protocols handshake. Notice it authenticates from the same session cookie as a normal request (find_session_by_cookie) and stamps the line with identified_by :current_user. So the open pipe isn't anonymous; Rails knows whose phone call this is for its entire life. One authenticated line per person.

Over that one line, Campfire runs many conversations at once — they're called channels, and each is a subscription multiplexed over the same socket: room_channel, presence_channel, typing_notifications_channel, unread_rooms_channel (app/channels/). The phone line is one; the topics you're listening for on it are many. That's why opening a room doesn't open a second socket — it just adds a subscription to the one you already have.

So what was that one magic line back in F4 — turbo_stream_from @room, :messages (app/views/rooms/show.html.erb:20)? Now you can read it precisely. It renders a tiny custom HTML element into the page (turbo-cable-stream-source, which even has styling in app/assets/stylesheets/base.css:72) whose entire job is to call that browser new WebSocket(...) for you and subscribe to a channel named after this room. You write one ERB tag; Turbo writes the JavaScript, dials the line, and registers your interest. That is the whole "real-time wiring."

A horizontal architecture / "stack" diagram on an off-white (#FAF8F3) backgroun…
And the push itself — the server finally speaking first — is the broadcast you already met (app/models/message/broadcasts.rb:2-4):

def broadcast_create
  broadcast_append_to room, :messages, target: [ room, :messages ]
  ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end

broadcast_append_to is Rails reaching for the open pipe of everyone subscribed to that room's channel and shoving a chunk of HTML down it. No browser asked. The server volunteered it, the line was open, and Turbo's onmessage (the one you saw above, written for you) catches the HTML and appends it to the DOM. That's the half-second-later message on four other screens — fully demystified.

This also closes the loop on a phrase from F4: Turbo — Frames & Streams that may have felt hand-wavy: the same _message partial reaches the screen by two transports. Now you can name them exactly. The sender gets their message as the in-band HTTP reply to their own form submit — request/response, the letter they were owed. Everyone else gets it out-of-band, pushed down the already-open WebSocket — the phone call. Same HTML, two pipes: one because you asked, one because the server could finally speak first. The wire carries HTML, not data over both — but only the WebSocket transport is the thing this tutorial was about.

One last honest detail, because it's why Action Cable earns its keep over the raw browser object. Phone lines drop. Wi-Fi blips, a laptop sleeps, a tunnel eats the signal — and the open pipe dies. With the raw WebSocket primitive, you'd have to detect the drop and redial, and worse, figure out what you missed while disconnected. Action Cable (with Turbo) handles the redial automatically, and F4b: Turbo 8 — Morphing & Live Refresh's "wake-from-sleep catch-up" is the answer to what you missed: on reconnect it re-requests and reconciles rather than replaying a queue. The capability is the browser's; the robustness around a flaky line is what the Rails wrapper is quietly buying you.

Key Takeaways — Patterns to Steal

  1. Plain HTTP is request/response — the server can never speak first. Every Rails action runs only because a browser asked. Real-time anything is impossible to express in that model, no matter how clever you get. This is the wall; the WebSocket is the only door through it.

  2. A WebSocket is a persistent two-way pipe — a phone call, not letters. Dialed once, it stays open so either side can send data the instant it has some. That single property — the server can speak first — is the entire reason it exists.

  3. It's born from an ordinary HTTP request that refuses to hang up. The browser sends a GET with Upgrade: websocket; the server answers 101 Switching Protocols instead of 200; the same connection then stays open as the pipe. Its URL scheme is ws:// / wss://. It is not a detour around the web — it's HTTP that transforms.

  4. The WebSocket is a native browser feature, not a Rails one. new WebSocket(url) has shipped in every browser since ~2011, right next to fetch. onmessage fires with no request behind it — that callback is the server speaking first. Everything Rails adds is convenience over this object.

  5. Polling is the naive climb, and it loses either way. Re-asking on a timer (<meta http-equiv="refresh"> or a fragment on a clock) either reacts late or hammers the server with requests that answer "nothing changed." The fix isn't a faster poll; it's a different connection.

  6. Action Cable is Rails' wrapper around ONE authenticated socket (connection.rb), authenticated once from the session cookie via identified_by :current_user. Many channels (room_channel, presence_channel, …) multiplex over that single pipe — opening a room adds a subscription, not a second socket.

  7. turbo_stream_from dials; broadcast_append_to speaks. One ERB tag (rooms/show.html.erb:20) makes Turbo open the socket and subscribe for you; the model's broadcast (message/broadcasts.rb:2-4) pushes HTML down every subscriber's open line. In-band HTTP reply for the sender, out-of-band socket push for everyone else — same partial, two transports.

  8. The wrapper's real value is surviving a flaky line. The raw primitive makes you handle drops, redials, and missed messages. Action Cable redials automatically and Turbo's wake-from-sleep catch-up reconciles what you missed — the capability is the browser's; the robustness is Rails'.