How to display a Connection Status widget in Rails
I saw a nifty Connection Status widget on another app today and wanted to build my own in Content Harmony, and came up with this solution using Stimulus and a simple /connection_status endpoint that serves a 200 response.
In short, the widget shows the user whether or not they're currently connected to our server.
Initially I was trying to hook this into an existing ActionCable or Turbo method to piggy back one of those existing connections. After fooling around for a bit I settled on a simpler solution that is disconnected from both of those.
I created a new /connection_status endpoint which serves a 200 response as well as plaintext message of Connected
. A Stimulus controller pings that endpoint every 15 seconds starting on page load. The Stimulus controller also checks the browser connection status that is reported.
As a result, in localhost, I can shut off my wi-fi and see an Offline
status because we're hooked into the browser's status, or I can shut down the local server after loading the page and see status change to Offline
, because the server is no longer responding to pings.
When either comes back online, the page displays Online
again.
Room for Improvement
There are lots of reasons why an app could be non-responsive and this widget is cool but presents some possible confusion points for users.
EG what if the server is responding but user is failing to see a validation error on the content they're saving? What if one of our services like Redis or Postgres or ________ is having downtime that isn't represented by our app?
My friend Karl commented "The main drawback is that you're only checking TCP connectivity. If your app is hung but the server underneath is running , it won't detect that."
So consider this a starting point to more complex scenarios that address those if it makes sense for your app.
Code Samples
_connection_status.html.erb
This is the view partial that creates the initial status on page load, and attaches to the Stimulus controller below.
<div data-controller="connection-status" class="w-32 flex justify-start items-center space-x-2 p-2 rounded-lg text-white text-sm shadow-sm">
<div data-connection-status-target="dot" class="w-2 h-2 rounded-full bg-yellow-500 transition-colors duration-1000"></div>
<span data-connection-status-target="message" class="transition-opacity duration-1000">Connecting...</span>
</div>
connection_status_controller.rb
This is the controller that handles the response to the connection status. It doesn't really need to respond with render plain: "Connected"
but that's a nice little visual confirmation that it's working.
# app/controllers/public/connection_status_controller.rb
class Public::ConnectionStatusController < ApplicationController
layout false
def show
render plain: "Connected"
end
end
If you wanted to make this more advanced you could serve a JSON response with key:value pairs of multiple app statuses to be more dynamic.
Think along the lines of this:
def show
status = {
database: database_connected?,
redis: redis_connected?,
sidekiq: sidekiq_healthy?,
cache: cache_connected?
}
end
Naturally this would also make the endpoint a lot heavier, so not sure what those tradeoffs will look like for all apps based upon usage patterns.
routes.rb
This creates the new endpoint at /connection_status
scope module: "public" do
get "connection_status", to: "connection_status#show"
end
connection_status_controller.js
This is the Stimulus controller that handles most of the logic. Jeremy Smith pointed out to me that you could refactor this up quite a bit with request.js which is already a dependency we use.
// app/javascript/controllers/connection_status_controller.js
import { Controller } from "@hotwired/stimulus";
import { Turbo } from "@hotwired/turbo-rails";
export default class extends Controller {
static targets = ["dot", "message"];
connect() {
this.connectionAttempts = 0;
console.log("Looking for connection...");
// Listen for browser's online/offline events
window.addEventListener("online", this.handleOnline.bind(this));
window.addEventListener("offline", this.handleOffline.bind(this));
// Listen for Turbo events
document.addEventListener(
"turbo:before-fetch-request",
this.handleRequest.bind(this)
);
document.addEventListener(
"turbo:before-fetch-response",
this.handleResponse.bind(this)
);
document.addEventListener(
"turbo:fetch-request-error",
this.handleError.bind(this)
);
// Start periodic connection check
this.startConnectionCheck();
// Initial status
if (navigator.onLine) {
this.checkConnection();
} else {
this.setStatus("offline");
}
}
async checkConnection() {
try {
const response = await fetch("/connection_status", {
method: "HEAD",
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
if (response.ok) {
this.setStatus("connected");
} else {
this.setStatus("offline");
}
} catch (error) {
this.setStatus("offline");
}
}
startConnectionCheck() {
// Check connection every 30 seconds
this.connectionCheckInterval = setInterval(() => {
this.checkConnection();
}, 15000);
}
handleOnline() {
console.log("Browser reports online, verifying connection...");
this.checkConnection();
}
handleOffline() {
console.log("Browser reports offline");
this.setStatus("offline");
}
handleRequest() {
if (this.currentStatus !== "connected") {
this.setStatus("connecting");
}
}
handleResponse() {
this.setStatus("connected");
this.connectionAttempts = 0;
}
handleError() {
this.connectionAttempts++;
this.setStatus("connecting");
this.startReconnectTimer();
}
startReconnectTimer() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
this.reconnectTimeout = setTimeout(() => {
if (this.connectionAttempts >= 3) {
this.setStatus("offline");
}
}, 30000);
}
setStatus(status) {
if (this.currentStatus !== status) {
this.currentStatus = status;
console.log(`Connection status: ${status}`);
// Update dot (color transition handled by CSS)
this.dotTarget.className = `w-2 h-2 rounded-full ${this.getStatusColor(
status
)} transition-colors duration-300`;
// Fade out text
this.messageTarget.style.opacity = "0";
// Wait for fade out, then update text and fade in
setTimeout(() => {
this.messageTarget.textContent = this.getStatusMessage(status);
this.messageTarget.style.opacity = "1";
}, 150); // Half of the transition duration for a smooth crossfade
}
}
getStatusColor(status) {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
case "offline":
return "bg-yellow-500";
default:
return "bg-gray-500";
}
}
getStatusMessage(status) {
switch (status) {
case "connected":
return "Connected";
case "connecting":
return "Connecting...";
case "offline":
return "Offline";
default:
return "Unknown";
}
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
if (this.connectionCheckInterval) {
clearInterval(this.connectionCheckInterval);
}
window.removeEventListener("online", this.handleOnline);
window.removeEventListener("offline", this.handleOffline);
document.removeEventListener(
"turbo:before-fetch-request",
this.handleRequest
);
document.removeEventListener(
"turbo:before-fetch-response",
this.handleResponse
);
document.removeEventListener("turbo:fetch-request-error", this.handleError);
}
}