Building a scalable chat application in Ruby on Rails requires careful architecture and consideration of performance bottlenecks. Through my experience developing real-time communication systems, I’ve identified nine essential techniques that make chat applications robust and scalable.
Implementing Effective WebSocket Management
WebSockets form the backbone of modern chat applications, providing persistent connections for real-time messaging. In Rails, Action Cable offers a seamless integration for WebSocket functionality.
The foundation begins with a well-structured channel. I prefer creating specialized channels for different chat functionalities:
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_#{params[:room_id]}"
ChatService.track_user_presence(current_user.id, params[:room_id])
end
def unsubscribed
ChatService.remove_user_presence(current_user.id, params[:room_id])
end
def send_message(data)
message = Message.create!(
content: data['content'],
room_id: params[:room_id],
user_id: current_user.id
)
MessageBroadcastJob.perform_later(message)
end
end
For the client-side connection, I implement a JavaScript consumer that manages connection states and reconnection logic:
// app/javascript/channels/chat_channel.js
import consumer from "./consumer"
const createChatChannel = (roomId) => {
return consumer.subscriptions.create({ channel: "ChatChannel", room_id: roomId }, {
connected() {
console.log("Connected to chat room: " + roomId)
},
disconnected() {
console.log("Disconnected from chat room")
// Implement reconnection logic
setTimeout(() => this.reconnect(), 3000)
},
received(data) {
// Handle incoming message
appendMessage(data.message)
},
sendMessage(content) {
this.perform('send_message', { content })
},
reconnect() {
// Custom reconnection logic
consumer.connect()
}
})
}
Scaling Message Broadcasting
As chat applications grow, message broadcasting becomes a potential bottleneck. I’ve found that offloading broadcasting to background jobs significantly improves performance:
# app/jobs/message_broadcast_job.rb
class MessageBroadcastJob < ApplicationJob
queue_as :default
def perform(message)
serialized_message = ActiveModelSerializers::SerializableResource.new(
message,
serializer: MessageSerializer
).as_json
ActionCable.server.broadcast(
"chat_#{message.room_id}",
message: serialized_message
)
# Additional notifications
NotificationService.notify_mentioned_users(message)
end
end
For high-volume chat rooms, I implement batching techniques to reduce the number of broadcast operations:
# app/services/batch_broadcast_service.rb
class BatchBroadcastService
def self.add_to_batch(room_id, message)
REDIS.lpush("broadcast_queue:#{room_id}", message.to_json)
REDIS.expire("broadcast_queue:#{room_id}", 5) # Set expiry
end
def self.process_batch(room_id)
messages = REDIS.lrange("broadcast_queue:#{room_id}", 0, -1)
return if messages.empty?
ActionCable.server.broadcast(
"chat_#{room_id}",
messages: messages.map { |m| JSON.parse(m) },
batch: true
)
REDIS.del("broadcast_queue:#{room_id}")
end
end
Implementing Efficient User Presence Tracking
User presence is crucial for chat applications. I leverage Redis sets for tracking online users with minimal overhead:
# app/services/chat_service.rb
class ChatService
def self.track_user_presence(user_id, room_id)
REDIS.sadd("presence:room:#{room_id}", user_id)
REDIS.expire("presence:room:#{room_id}", 86400) # 24 hours
broadcast_presence_update(room_id)
end
def self.remove_user_presence(user_id, room_id)
REDIS.srem("presence:room:#{room_id}", user_id)
broadcast_presence_update(room_id)
end
def self.broadcast_presence_update(room_id)
user_ids = REDIS.smembers("presence:room:#{room_id}")
users = User.where(id: user_ids).select(:id, :username, :avatar_url)
ActionCable.server.broadcast(
"presence_#{room_id}",
users: users.as_json(only: [:id, :username, :avatar_url])
)
end
def self.heartbeat(user_id, room_id)
# Refresh user presence with each heartbeat
track_user_presence(user_id, room_id)
end
end
On the client side, I implement heartbeat mechanisms to maintain accurate presence information:
// app/javascript/channels/presence_channel.js
import consumer from "./consumer"
const createPresenceChannel = (roomId) => {
const channel = consumer.subscriptions.create({ channel: "PresenceChannel", room_id: roomId }, {
connected() {
this.startHeartbeat()
},
disconnected() {
this.stopHeartbeat()
},
received(data) {
updateOnlineUsers(data.users)
},
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
this.perform('heartbeat')
}, 30000) // 30 seconds
},
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
}
}
})
return channel
}
Message Persistence and History Management
Efficient message storage and retrieval are essential for a smooth chat experience. I implement pagination and caching to optimize history loading:
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def index
@room = Room.find(params[:room_id])
authorize @room, :show?
@messages = @room.messages
.includes(:user)
.order(created_at: :desc)
.page(params[:page])
.per(50)
response.headers["X-Total-Pages"] = @messages.total_pages.to_s
render json: @messages, each_serializer: MessageSerializer
end
end
For optimized message retrieval, I implement a caching strategy:
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :room
belongs_to :user
after_create_commit :clear_cache
def self.recent_for_room(room_id, limit = 50)
Rails.cache.fetch("room_#{room_id}/recent_messages", expires_in: 5.minutes) do
where(room_id: room_id)
.includes(:user)
.order(created_at: :desc)
.limit(limit)
.to_a
end
end
private
def clear_cache
Rails.cache.delete("room_#{room_id}/recent_messages")
end
end
Implementing Robust Notification Handling
Notifications are vital for user engagement. I create a comprehensive system for handling various notification types:
# app/services/notification_service.rb
class NotificationService
def self.notify_mentioned_users(message)
mentioned_users = extract_mentions(message.content)
mentioned_users.each do |username|
user = User.find_by(username: username)
next unless user
create_mention_notification(user, message)
push_notification_to_user(user, "You were mentioned by #{message.user.username}", message.content)
end
end
def self.create_mention_notification(user, message)
Notification.create!(
recipient: user,
actor: message.user,
action: 'mentioned',
notifiable: message,
read: false
)
end
def self.push_notification_to_user(user, title, body)
return unless user.push_enabled?
PushNotificationJob.perform_later(
user_id: user.id,
title: title,
body: body,
data: { message_id: message.id, room_id: message.room_id }
)
end
def self.extract_mentions(content)
content.scan(/@(\w+)/).flatten.uniq
end
end
To deliver notifications to offline users, I implement a push notification service:
# app/jobs/push_notification_job.rb
class PushNotificationJob < ApplicationJob
queue_as :notifications
def perform(user_id:, title:, body:, data:)
user = User.find(user_id)
return unless user&.push_subscription.present?
Webpush.payload_send(
message: JSON.generate({
title: title,
body: body,
data: data
}),
endpoint: user.push_subscription['endpoint'],
p256dh: user.push_subscription['p256dh'],
auth: user.push_subscription['auth'],
vapid: {
subject: 'mailto:[email protected]',
public_key: ENV['VAPID_PUBLIC_KEY'],
private_key: ENV['VAPID_PRIVATE_KEY']
}
)
rescue Webpush::InvalidSubscription
user.update(push_subscription: nil)
end
end
Creating a Scalable Database Architecture
Database design is crucial for chat application performance. I’ve found that proper indexing and denormalization significantly improve query performance:
# db/migrate/20230415123456_create_messages.rb
class CreateMessages < ActiveRecord::Migration[6.1]
def change
create_table :messages do |t|
t.text :content
t.references :user, null: false, foreign_key: true
t.references :room, null: false, foreign_key: true
t.integer :message_type, default: 0
t.json :metadata
t.timestamps
end
add_index :messages, [:room_id, :created_at]
add_index :messages, :created_at
end
end
For rooms with high message volumes, I implement partitioning to improve query performance:
# app/models/concerns/message_partitioning.rb
module MessagePartitioning
extend ActiveSupport::Concern
class_methods do
def partition_table
connection.execute(<<-SQL)
CREATE TABLE IF NOT EXISTS messages_partitioned (
id BIGSERIAL,
content TEXT,
user_id BIGINT NOT NULL,
room_id BIGINT NOT NULL,
message_type INTEGER DEFAULT 0,
metadata JSONB,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);
SQL
# Create monthly partitions for a year
12.times do |i|
start_date = Time.current.beginning_of_year + i.months
end_date = start_date + 1.month
connection.execute(<<-SQL)
CREATE TABLE IF NOT EXISTS messages_#{start_date.strftime('%Y_%m')}
PARTITION OF messages_partitioned
FOR VALUES FROM ('#{start_date.to_s(:db)}') TO ('#{end_date.to_s(:db)}');
SQL
end
end
end
end
Implementing Efficient File and Media Sharing
Chat applications often require file sharing capabilities. I implement direct-to-S3 uploads to minimize server load:
# app/controllers/attachments_controller.rb
class AttachmentsController < ApplicationController
def presigned_url
authorize :attachment, :create?
room = Room.find(params[:room_id])
authorize room, :show?
filename = "#{SecureRandom.uuid}/#{params[:filename]}"
content_type = params[:content_type]
presigned_post = S3_BUCKET.presigned_post(
key: "uploads/#{room.id}/#{filename}",
success_action_status: '201',
acl: 'public-read',
content_type: content_type,
expires: 1.hour.from_now
)
render json: {
presigned_url: presigned_post.url,
fields: presigned_post.fields,
file_url: "#{ENV['S3_ASSET_URL']}/uploads/#{room.id}/#{filename}"
}
end
end
On the client side, I implement a direct upload mechanism:
// app/javascript/utils/file_upload.js
const uploadFile = async (file, roomId) => {
try {
// Get presigned URL
const presignResponse = await fetch(`/rooms/${roomId}/attachments/presigned_url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
filename: file.name,
content_type: file.type
})
});
const presignData = await presignResponse.json();
// Create form data for direct S3 upload
const formData = new FormData();
Object.entries(presignData.fields).forEach(([k, v]) => {
formData.append(k, v);
});
formData.append('file', file);
// Upload directly to S3
const uploadResponse = await fetch(presignData.presigned_url, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) throw new Error('Upload failed');
return presignData.file_url;
} catch (error) {
console.error('Error uploading file:', error);
throw error;
}
};
Implementing Advanced Message Features
To enhance the chat experience, I implement typing indicators with debouncing to reduce unnecessary broadcasts:
# app/channels/typing_channel.rb
class TypingChannel < ApplicationCable::Channel
def subscribed
stream_from "typing_#{params[:room_id]}"
end
def unsubscribed
stop_typing
end
def start_typing
REDIS.setex(
"typing:#{params[:room_id]}:#{current_user.id}",
5, # Expire after 5 seconds
current_user.username
)
broadcast_typing_status
end
def stop_typing
REDIS.del("typing:#{params[:room_id]}:#{current_user.id}")
broadcast_typing_status
end
private
def broadcast_typing_status
typing_users = REDIS.keys("typing:#{params[:room_id]}:*").map do |key|
user_id = key.split(':').last
username = REDIS.get(key)
{ id: user_id, username: username }
end
ActionCable.server.broadcast(
"typing_#{params[:room_id]}",
typing_users: typing_users
)
end
end
On the client side, I implement debouncing to prevent excessive typing events:
// app/javascript/utils/typing_indicator.js
class TypingIndicator {
constructor(channel) {
this.channel = channel;
this.timeout = null;
this.isTyping = false;
}
handleInput() {
if (!this.isTyping) {
this.startTyping();
}
this.resetTimeout();
}
startTyping() {
this.isTyping = true;
this.channel.perform('start_typing');
}
stopTyping() {
this.isTyping = false;
this.channel.perform('stop_typing');
}
resetTimeout() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.stopTyping(), 3000);
}
}
// Usage
const typingChannel = consumer.subscriptions.create({ channel: "TypingChannel", room_id: roomId });
const typingIndicator = new TypingIndicator(typingChannel);
document.querySelector('#message-input').addEventListener('input', () => {
typingIndicator.handleInput();
});
Performance Monitoring and Optimization
To maintain performance as the application scales, I implement comprehensive monitoring:
# config/initializers/performance_monitoring.rb
if Rails.env.production?
# Configure Datadog APM
Datadog.configure do |c|
c.use :rails, service_name: 'chat-application'
c.use :redis, service_name: 'chat-redis'
c.use :sidekiq, service_name: 'chat-sidekiq'
c.use :action_cable, service_name: 'chat-action-cable'
end
# Custom instrumentation for chat operations
module ChatInstrumentation
def self.trace_message_delivery(room_id, message_id)
Datadog::Tracing.trace('chat.message.delivery', service: 'chat-delivery') do |span|
span.set_tag('room_id', room_id)
span.set_tag('message_id', message_id)
yield if block_given?
end
end
end
end
For connection management, I implement adaptable concurrency limits:
# config/initializers/action_cable.rb
Rails.application.config.action_cable.worker_pool_size = ENV.fetch('ACTION_CABLE_WORKER_POOL_SIZE', 4).to_i
# Custom connection monitor
module ConnectionMonitor
REDIS_KEY = 'action_cable:connection_count'
def self.increment
REDIS.incr(REDIS_KEY)
end
def self.decrement
REDIS.decr(REDIS_KEY)
end
def self.connection_count
REDIS.get(REDIS_KEY).to_i
end
end
# In ApplicationCable::Connection
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
ConnectionMonitor.increment
# Adaptive puma scaling based on connection count
if ConnectionMonitor.connection_count > 1000
# Trigger autoscaling through your provider's API
end
end
def disconnect
ConnectionMonitor.decrement
end
end
end
In my experience building chat applications, these nine techniques have proven essential for scalability. By focusing on efficient WebSocket management, message broadcasting, user presence tracking, history management, notification handling, database architecture, file sharing, advanced features, and performance monitoring, I’ve created chat systems that maintain performance even with thousands of concurrent users.
The key to success lies in thoughtful architecture from the start and continuous optimization as the application grows. Each component must be designed with scalability in mind, using background processing for potentially blocking operations and leveraging Redis for fast, in-memory operations.
I encourage testing these implementations under load conditions before deployment to production, as real-world performance can differ from development expectations. With these patterns, your Ruby on Rails chat application will be well-positioned to scale as your user base grows.