Introduction
As a Ruby on Rails architect specializing in scalable applications, I value real-time communication for delivering responsive user experiences. WebSockets provide a persistent, low-latency channel ideal for chat, collaboration, and live feeds. For WebSocket adoption and trend references, consult Statista (https://www.statista.com/) and search for "WebSocket" or "real-time web".
This article demonstrates a practical, production-minded Rails 7 + PostgreSQL chat implementation using Action Cable. You’ll get focused, Rails-specific code and operational guidance to ship a reliable real-time feature. Highlights:
- Rails 7 Action Cable channel, connection identification, and client consumer examples
- PostgreSQL-backed Message model and migration with indexing and maintenance guidance
- Controller & model flow that persists messages and broadcasts via Action Cable or Turbo Streams
- Production configuration (Redis adapter, cable.yml), security best practices, and troubleshooting tips
Introduction to WebSockets: The Need for Real-time Communication
Understanding Real-time Communication
Users expect instant updates in apps—chat, collaborative editors, live dashboards, multiplayer games. Traditional HTTP (request/response) adds round-trip latency and overhead for frequent updates. WebSockets open a persistent TCP-backed channel after an HTTP upgrade handshake, enabling both client and server to push messages at any time.
- Persistent connection for continuous data flow
- Lower per-message overhead compared with repeated HTTP requests
- Support for text and binary frames for varied payloads
How WebSockets Work: Behind the Scenes of Real-time Data Transfer
The WebSocket Protocol
The client issues an HTTP Upgrade request and the server switches protocols to WebSocket. The canonical specification is RFC 6455 (WebSocket Protocol); see the IETF homepage (https://www.ietf.org/) and search for RFC 6455 for the formal spec. After upgrade, the connection is full-duplex and frame-oriented. Good implementations apply the following optimizations:
- Use binary frames for compact payloads when appropriate
- Enable permessage-deflate on the server and client for compressible text
- Keep messages small and batch high-frequency events
Setting Up a Simple WebSocket Server: A Hands-on Guide
Quick Node.js example (reference)
For a minimal WebSocket server in Node.js (helpful for testing or prototyping), the ws library is a small, focused dependency. Install Node.js from https://nodejs.org/ and then:
npm init -y
npm install ws
const WebSocket = require('ws');
const server = new WebSocket.Server({ port: 8080 });
server.on('connection', ws => {
ws.on('message', message => console.log('Received:', message));
ws.send('Hello from server!');
});
This snippet is for quick testing only. The rest of this article focuses on a Rails 7 production flow using Action Cable and PostgreSQL.
Action Cable with Rails 7: Creating a Rails Chat Application
This section shows a minimal, production-minded flow: clients subscribe to a channel, users post messages via a controller, messages are persisted in PostgreSQL, and broadcasts push updates to subscribers. It includes alternatives: rendered HTML broadcasts, JSON payloads, and Turbo Streams.
Prerequisites
- Ruby 3.1+ and Rails 7.0.x
- PostgreSQL 13+ and the
pggem (example:pg>= 1.4) - Redis for production Action Cable adapter (Redis 6 or 7 recommended) and the
redisgem (e.g., 4.x) - Authentication (Devise or similar) for identifying users
1) Database: Message migration
Use proper types and indexes. If you have a separate rooms table, prefer a references column with a foreign key. If rooms are lightweight, an integer key and an index work as well.
# db/migrate/2025xxxxxx_create_messages.rb
class CreateMessages < ActiveRecord::Migration[7.0]
def change
create_table :messages do |t|
t.references :user, null: false, foreign_key: true
t.bigint :room_id, null: false, index: true
t.text :content, null: false
t.timestamps
end
add_index :messages, [:room_id, :created_at]
end
end
2) Message model with broadcasting (HTML and JSON examples)
Keep persistence and broadcast decoupled. For small teams, broadcasting rendered partials is simple; for large frontends you may prefer JSON payloads and client-side rendering.
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :user
validates :content, presence: true, length: { maximum: 10_000 }
after_create_commit do
# Option A: Broadcast rendered HTML (simple, server-rendered)
html = ApplicationController.renderer.render(partial: 'messages/message', locals: { message: self })
ActionCable.server.broadcast("chat_room_#{room_id}", { type: 'message:html', message_html: html, id: id })
# Option B: Broadcast JSON for richer client-side apps
# ActionCable.server.broadcast("chat_room_#{room_id}", { type: 'message:json', message: as_json(only: [:id, :content, :created_at], include: { user: { only: [:id, :name] } }) })
end
end
3) Action Cable channel
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
room_id = params[:room_id].to_i
reject unless room_id.positive? && authorized_for_room?(room_id)
stream_from("chat_room_#{room_id}")
end
def unsubscribed
# cleanup if needed
end
private
def authorized_for_room?(room_id)
# Implement room access logic. Example: check membership table or permissions.
true
end
end
4) Controller to create messages
Use strong params, authenticate users, and render appropriate responses for AJAX/API clients. Add explicit error handling and logging to capture validation failures or unexpected issues.
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
before_action :authenticate_user!
def create
message = current_user.messages.create!(message_params)
# If you prefer immediate JSON response for SPA clients:
render json: { id: message.id, room_id: message.room_id }, status: :created
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info("Message create failed: ")
Rails.logger.info(e.record.errors.full_messages.join(', '))
render json: { error: e.record.errors.full_messages.join(', ') }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error("Unexpected error creating message: #{e.class}: #{e.message}")
render json: { error: 'Internal error' }, status: :internal_server_error
end
private
def message_params
params.require(:message).permit(:content, :room_id)
end
end
5) Client consumer (Rails 7 importmap / asset pipeline)
Create a consumer subscription that primarily targets JSON payloads for client-side rendering. Provide robust error handling on the client and handlers for lifecycle events.
// app/javascript/channels/chat_channel.js
import consumer from './consumer'
export default function createSubscription(roomId, handlers = {}) {
const subscription = consumer.subscriptions.create(
{ channel: 'ChatChannel', room_id: roomId },
{
connected() {
if (handlers.onConnected) handlers.onConnected()
},
disconnected() {
if (handlers.onDisconnected) handlers.onDisconnected()
},
received(rawData) {
try {
const data = rawData
if (data.type === 'message:json' && handlers.onJson) {
handlers.onJson(data.message || data)
} else if (data.type === 'message:html' && handlers.onHtml) {
// Provide a safe opt-in path for server-rendered HTML insertion.
handlers.onHtml(data.message_html, data.id)
}
} catch (err) {
console.error('Error handling subscription message:', err)
if (handlers.onError) handlers.onError(err)
}
},
rejected() {
if (handlers.onRejected) handlers.onRejected()
}
}
)
// Add simple unsubscribe wrapper that swallows expected errors
function safeUnsubscribe() {
try { subscription.unsubscribe() } catch (e) { /* ignore */ }
}
return Object.assign(subscription, { safeUnsubscribe })
}
// Example usage for JSON-first clients:
// const sub = createSubscription(42, { onJson: (msg) => handleIncomingMessage(msg) })
// sub.safeUnsubscribe()
6) Simple message partial (server-rendered)
With this pattern, persistence and broadcasting are decoupled: the message is saved to PostgreSQL and then broadcast to subscribers as rendered HTML or JSON.
Connection Identification & Authentication
Identify users at the connection level so Action Cable knows who is connected. Use signed/encrypted cookies or the authentication middleware (Devise/Warden) available in the connection environment.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
reject_unauthorized_connection unless current_user
end
private
def find_verified_user
# If using Devise/Warden:
env['warden']&.user
# OR, using signed cookies (example):
# User.find_by(id: cookies.signed[:user_id])
end
end
end
Security notes:
- Only identify using server-side, signed values—do not trust client-sent user IDs without verification.
- Reject connections early if authentication fails to avoid wasted resources.
- Log connection attempts and rejections in staging for visibility into auth edge cases.
Turbo Streams Integration Example
Rails 7 and Turbo Streams simplify real-time updates by broadcasting Turbo Stream fragments. This keeps client-side code minimal and leverages Turbo to update the DOM.
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :user
after_create_commit -> { broadcast_append_to "chat_room_#{room_id}", target: "messages_room_#{room_id}" }
end
With the above, create a partial _message.html.erb and a container with id="messages_room_#{room.id}". Turbo will handle inserting the fragment.
If you need to broadcast from a controller or background job:
# anywhere in app code
render turbo_stream: turbo_stream.append("messages_room_#{room_id}", partial: 'messages/message', locals: { message: message })
Benefits:
- Minimal JS on the client—Turbo parses and applies stream updates
- Server-side rendering keeps markup consistent
Production Configuration (Redis & cable.yml)
For multi-process or multi-server deployments, configure Action Cable with Redis as the Pub/Sub adapter. This ensures messages broadcast from one process reach subscribers connected to other processes.
# config/cable.yml
production:
adapter: redis
url: <%= ENV['REDIS_URL'] || 'redis://localhost:6379/1' %>
Example Rails config to set allowed request origins (do this in config/environments/production.rb):
# config/environments/production.rb
config.action_cable.url = ENV.fetch('ACTION_CABLE_URL', 'wss://your-app.example.com/cable')
config.action_cable.allowed_request_origins = [ ENV.fetch('APP_HOST', 'https://your-app.example.com') ]
Operational tips and security considerations:
- Use a managed Redis (or well-monitored Redis cluster) to avoid single points of failure. Prefer Redis 6+ and enable AUTH and TLS where supported.
- Run cable processes in separate dynos/containers or use a dedicated pool to avoid interfering with web request capacity.
- Keep long-lived connections behind load balancers that support HTTP Upgrade and either sticky sessions or header-based routing for WebSockets.
- Limit message sizes and apply authentication at the connection layer to prevent abuse.
Persistent Storage with PostgreSQL: Persisting Messages
PostgreSQL stores all chat history reliably and enables querying, indexing, and retention policies. Implementation notes:
- Use foreign keys and appropriate indexes (e.g., index on
room_idandcreated_at) for fast retrieval. - Consider partitioning (time-based) or retention/TTL cleanup for high-throughput apps to control table growth.
- Enforce content length limits and use prepared statements to avoid large payloads and SQL overhead.
For heavy write loads, route Action Cable through Redis (pub/sub) and use horizontally scaled cable workers. Offload expensive rendering to background jobs when needed:
# app/jobs/message_broadcast_job.rb
class MessageBroadcastJob < ApplicationJob
queue_as :default
def perform(message_id)
message = Message.find(message_id)
html = ApplicationController.renderer.render(partial: 'messages/message', locals: { message: message })
ActionCable.server.broadcast("chat_room_#{message.room_id}", { type: 'message:html', message_html: html, id: message.id })
end
end
# Trigger: MessageBroadcastJob.perform_later(message.id) in after_create_commit
Integrating WebSockets with Frontend Frameworks: Best Practices
Seamless Communication with Frameworks
Whether you use vanilla JS, React, or another framework, follow these rules:
- Open subscriptions inside lifecycle hooks (e.g., React's useEffect) and always close them on unmount.
- Keep UI updates idempotent and keyed so duplicate messages don't render if reconnection replay occurs.
- Use optimistic UI only when you can reconcile with server persistence to avoid state drift.
- Use Socket.IO (https://socket.io/) only if you need fallbacks or extra features; Action Cable is idiomatic for Rails apps.
For detailed guidance on avoiding server-rendered HTML insertion into framework-managed DOMs, see the React section below which provides explicit patterns and safety notes.
React (JSON-first) Example
The recommended approach for React (or other component-driven frameworks) is to receive JSON payloads over Action Cable and render them as React components. This avoids DOM inconsistencies and security risks from injecting server-rendered HTML.
// app/javascript/components/Chat.jsx
import React, { useEffect, useRef, useState } from 'react'
import createSubscription from '../channels/chat_channel'
function Message({ message }) {
return (
{message.user?.name || 'Unknown'}
{new Date(message.created_at).toLocaleTimeString()}
{message.content}
)
}
export default function Chat({ roomId, initialMessages = [] }) {
const [messages, setMessages] = useState(initialMessages)
const mountedRef = useRef(false)
useEffect(() => {
mountedRef.current = true
const sub = createSubscription(roomId, {
onJson: (payload) => {
const msg = payload.message || payload
setMessages(prev => {
if (prev.some(m => m.id === msg.id)) return prev
return [...prev, msg]
})
},
onError: (err) => console.error('Subscription error', err)
})
return () => {
mountedRef.current = false
try { sub.safeUnsubscribe() } catch (e) { /* ignore */ }
}
}, [roomId])
return (
{messages.map(m => (
))}
)
}
Notes and best practices:
- Ensure the server sends a compact JSON shape. Example: { id, content, created_at, user: { id, name } }.
- Use keys in React lists (message.id) to avoid re-rendering and duplicates.
- If you must display HTML content from the server, sanitize it server-side and client-side and render with caution using an explicit API (e.g., using a sanitized HTML rendering component). Prefer plain-text or markdown-to-HTML conversion on the server where you can control sanitization libraries.
- Test reconnection and duplicate message scenarios (e.g., at-least-once delivery patterns) and write idempotent client logic.
Common Use Cases for WebSockets: Enhancing User Experience
WebSockets are widely used for:
- Real-time chat and presence indicators
- Collaborative document editing (operational transforms / CRDTs)
- Live dashboards and notifications
- Multiplayer game state synchronization
- Financial market tickers and low-latency feeds
Troubleshooting and Optimizing WebSocket Connections
Common Issues and Fixes
- Connection drops: implement exponential backoff reconnection and keepalive pings (example below).
- Load balancer rejects upgrades: enable sticky sessions or configure the balancer to support HTTP Upgrade and long-lived connections.
- Firewall or proxy timeouts: use periodic pings to keep connections active and tune idle timeouts.
- Excessive server load: batch messages server-side and use broadcasting channels to limit per-connection work.
Example reconnection strategy (client-side)
function connectWithBackoff(url) {
let attempt = 0
let socket
const connect = () => {
socket = new WebSocket(url)
socket.onclose = () => {
attempt++
const delay = Math.min(30000, 1000 * Math.pow(2, attempt))
setTimeout(connect, delay)
}
socket.onerror = (err) => {
console.error('WebSocket error', err)
}
return socket
}
return connect()
}
Performance optimizations
- Enable permessage-deflate compression on server & client when messages are compressible.
- Batch frequent small updates instead of sending them individually.
- Limit message size and enforce server-side validation to avoid abuse.
- Use Redis pub/sub adapter for Action Cable in multi-process deployments.
Tools & diagnostics
- Use browser DevTools network panel to inspect WebSocket frames.
- Test with CLI tools such as wscat for connectivity checks.
- Log Action Cable connections and messages in staging to capture edge cases before production.
Key Takeaways
- Action Cable in Rails 7 pairs well with PostgreSQL for persistent chat: save first, then broadcast the rendered message or JSON payload.
- Secure your WebSocket endpoints: enforce authentication at connection time, use TLS (wss://), validate allowed origins, and limit payload sizes.
- Scale reliably by using Redis as the pub/sub adapter and by sharding/partitioning data when necessary.
- Test reconnection, throttling, and failure modes early—these are common causes of poor user experience.
Conclusion
WebSockets are a powerful tool for building interactive real-time applications. For Rails developers, Action Cable provides an integrated, idiomatic way to implement WebSocket features while PostgreSQL ensures reliable persistence. Use the patterns shown here—persist then broadcast, validate and sanitize input, secure connections with TLS and origin checks, and scale with Redis—to build production-ready real-time features.
For further reading and project homepages: Node.js, Socket.IO, and wscat. For Rails and Action Cable specifics consult the Rails project resources at https://rubyonrails.org/.
Socket.IO: Comparison and Use Cases
Socket.IO is listed as a further-reading resource for general WebSocket concepts and feature comparison, not as a replacement recommendation for Action Cable in Rails-first projects. Socket.IO adds features like automatic reconnection, fallback transports for older clients, and built-in acknowledgement semantics which can be useful in heterogeneous ecosystems. For Rails applications, prefer Action Cable for idiomatic integration unless you need cross-platform client parity or specific Socket.IO features.
