Introduction
As your application scales, inefficient Action Cable usage can become a bottleneck. Optimizing broadcast frequency, payload size, and subscription management keeps your real-time features responsive without overwhelming the server or client.
Key Concepts
- Broadcast Debouncing: Reducing the frequency of broadcasts by batching or throttling updates, especially for rapidly changing data.
- Payload Compression: Minimizing the size of broadcast messages to reduce bandwidth usage and improve delivery speed.
- Subscription Scoping: Ensuring clients subscribe only to the streams they actively need, reducing the number of messages processed per connection.
- Background Job Broadcasting: Moving broadcast logic to background jobs to avoid blocking the request cycle.
Real World Context
Imagine a live dashboard showing real-time order counts. If every new order triggers a broadcast, a flash sale generating 100 orders per second produces 100 broadcasts per second to every dashboard viewer. By debouncing — broadcasting the updated count every 2 seconds instead of on every order — you reduce traffic by 99% with minimal impact on perceived real-time accuracy.
Deep Dive
Debouncing Broadcasts
For data that changes frequently, batch updates in a background job:
ruby# app/jobs/dashboard_broadcast_job.rb class DashboardBroadcastJob < ApplicationJob def perform(dashboard_id) dashboard = Dashboard.find(dashboard_id) DashboardChannel.broadcast_to(dashboard, { type: "stats_update", data: { order_count: dashboard.orders.today.count, revenue: dashboard.revenue_today, active_users: dashboard.active_user_count } }) end end # Schedule periodically using Solid Queue or a recurring job # Instead of broadcasting on every order creation
Minimizing Payload Size
Every byte sent through a WebSocket is multiplied by the number of subscribers. Keep payloads lean:
ruby# Bad: 2KB payload with unnecessary data ChatChannel.broadcast_to(room, message.as_json(include: { user: { include: :profile } })) # Good: 200-byte payload with only what the UI needs ChatChannel.broadcast_to(room, { type: "msg", id: message.id, body: message.body, uid: message.user_id, name: message.user.username, at: message.created_at.to_i })
For even smaller payloads, use short key names and Unix timestamps instead of ISO 8601 strings.
Managing Subscriptions
Clean up subscriptions when they are no longer needed:
rubyclass ChatChannel < ApplicationCable::Channel def subscribed @room = Room.find(params[:room_id]) stream_for @room # Track presence current_user.update!(last_seen_in_room: @room.id) end def unsubscribed # Clean up presence tracking current_user.update!(last_seen_in_room: nil) if @room stop_all_streams end end
The stop_all_streams method in unsubscribed explicitly cleans up all streams for the subscription. While Action Cable calls this automatically, being explicit makes your intent clear.
N+1 Query Prevention
When broadcasting involves database queries, watch for N+1 patterns:
ruby# Bad: N+1 queries in a broadcast loop room.users.each do |user| NotificationsChannel.broadcast_to(user, { message: "New update" }) end # Better: Single broadcast to the room stream RoomChannel.broadcast_to(room, { type: "update", message: "New update" })
Common Pitfalls
- Broadcasting on every database change — High-frequency updates like counters or timestamps should be batched or debounced, not broadcast individually.
- Leaving stale subscriptions open — If a user navigates away from a page but the subscription is not cleaned up, they continue receiving (and ignoring) broadcasts, wasting resources.
- Querying the database inside
receivedon the server — Channel actions should be lean. Move complex queries to background jobs.
Best Practices
- Debounce high-frequency broadcasts — For data that changes multiple times per second, broadcast on a timer (every 1-5 seconds) rather than on every change.
- Use the smallest possible payload — Every byte is multiplied by every subscriber. Short keys, Unix timestamps, and minimal nesting reduce bandwidth.
- Unsubscribe clients when they navigate away — Use Turbo's lifecycle hooks or
disconnectedcallbacks to clean up subscriptions that are no longer visible.
Summary
- Debounce broadcasts for rapidly changing data to reduce message volume by orders of magnitude.
- Minimize payload size — every byte is multiplied by the subscriber count.
- Clean up subscriptions in
unsubscribedto free server resources. - Move broadcast logic to background jobs to keep the request cycle fast.
- Watch for N+1 queries in broadcast loops — prefer single stream broadcasts over per-user loops.
Code Examples
# Debounced broadcasting via a periodic background job
class DashboardBroadcastJob < ApplicationJob
def perform(dashboard_id)
dashboard = Dashboard.find(dashboard_id)
DashboardChannel.broadcast_to(dashboard, {
type: "stats_update",
data: {
order_count: dashboard.orders.today.count,
revenue: dashboard.revenue_today
}
})
end
end
# Run every 2 seconds instead of on every order