← Back to articles

How to display a Connection Status widget in Rails

Last Updated: January 30, 2025 868 Words Read Time: 4 mins
Categories: Web Development

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.

/files/70cd98/image.png

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);
  }
}