Rails 8 SolidCable: Database-Backed WebSockets Guide

ActionCable brought WebSocket support to Rails, but it traditionally required Redis as a message broker. This added complexity and cost to deployments.

Rails 8 introduces SolidCable as the default ActionCable adapter. It stores messages in the database and uses polling to deliver them to subscribers. This eliminates Redis as a dependency for real time features.

In this post, we will explore how SolidCable works, how to configure it for production, and important considerations for the application.

How SolidCable Works

Traditional ActionCable with Redis uses pub/sub: when a message is broadcast, Redis immediately pushes it to all subscribers.

SolidCable takes a different approach. Messages are written to a database table and subscribers poll for new messages. Despite the polling mechanism, performance is comparable to Redis for most use cases.

This database-driven approach was not practical in the past. Modern infrastructure with NVMe SSDs has made it viable due to extremely fast read/write speeds and low storage latency.

For PostgreSQL, SolidCable can use LISTEN/NOTIFY for more efficient message delivery.

Installation

SolidCable is preconfigured in new Rails 8 applications. For existing applications:

bundle add solid_cable
bin/rails solid_cable:install

This creates:

  • Updated config/cable.yml configuration
  • db/cable_schema.rb for the database schema

Database Configuration

Configure a dedicated database for SolidCable:

# config/database.yml



production:

  primary:

    <<: *default

    database: storage/production.sqlite3

  cable:

    <<: *default

    database: storage/production_cable.sqlite3

    migrations_paths: db/cable_migrate

For PostgreSQL:

# config/database.yml



production:

  primary:

    <<: *default

    database: myapp_production

  cable:

    <<: *default

    database: myapp_cable_production

    migrations_paths: db/cable_migrate

Run the setup:

bin/rails db:prepare

Configuration Options

Configure SolidCable in config/cable.yml:

# config/cable.yml



production:

  adapter: solid_cable

  connects_to:

    database:

      writing: cable

  polling_interval: 0.1.seconds

  message_retention: 1.day

Key options:

  • polling_interval: How often clients check for new messages (default: 0.1 seconds)
  • message_retention: How long messages are kept before cleanup (default: 1 day)
  • autotrim: Whether to automatically clean old messages (default: true)

Real World Usage Examples

Live Notifications

Build a simple notification system that updates in real time:

# app/channels/notifications_channel.rb


class NotificationsChannel < ApplicationCable::Channel
  def subscribed
    stream_from "notifications_#{current_user.id}"
  end
end

Broadcast a notification from anywhere in the application:

# Broadcast from a controller, model, or job

ActionCable.server.broadcast(
  "notifications_#{user.id}",
  { message: "You have a new message!" }
)

Handle the notification on the client:

// app/javascript/channels/notifications_channel.js


import consumer from "./consumer"

consumer.subscriptions.create("NotificationsChannel", {
  received(data) {
    alert(data.message)
  }
})

Live Dashboard Updates

Create a dashboard that updates metrics in real time:

# app/channels/dashboard_channel.rb


class DashboardChannel < ApplicationCable::Channel
  def subscribed
    stream_from "dashboard_#{params[:team_id]}"
  end
end

Broadcast updated metrics:

ActionCable.server.broadcast(
  "dashboard_#{team.id}",
  { active_users: 42, orders_today: 156 }
)
// app/javascript/channels/dashboard_channel.js


import consumer from "./consumer"

consumer.subscriptions.create(
  { channel: "DashboardChannel", team_id: 1 },
  {
    received(data) {
      document.getElementById("active-users").textContent = data.active_users
      document.getElementById("orders-today").textContent = data.orders_today
    }
  }
)

Collaborative Editing

Sync a shared document in real time across all connected users. When one user types, everyone else sees the update immediately:

# app/channels/document_channel.rb


class DocumentChannel < ApplicationCable::Channel
  def subscribed
    stream_from "document_#{params[:id]}"
  end

  def update_content(data)
    ActionCable.server.broadcast(
      "document_#{params[:id]}",
      { user: current_user.name, content: data["content"] }
    )
  end
end
// app/javascript/channels/document_channel.js


import consumer from "./consumer"

const channel = consumer.subscriptions.create(
  { channel: "DocumentChannel", id: 1 },
  {
    received(data) {
      // Update the shared textarea for all other collaborators

      document.getElementById("document-content").value = data.content
    }
  }
)

// Broadcast changes whenever the user types

document.getElementById("document-content").addEventListener("input", (e) => {
  channel.perform("update_content", { content: e.target.value })
})

Chat Application

Build a real time chat feature:

# app/channels/chat_channel.rb


class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from "chat_room_#{params[:room_id]}"
  end

  def speak(data)
    ActionCable.server.broadcast(
      "chat_room_#{params[:room_id]}",
      { user: current_user.name, message: data["message"] }
    )
  end
end
// app/javascript/channels/chat_channel.js


import consumer from "./consumer"

const chat = consumer.subscriptions.create(
  { channel: "ChatChannel", room_id: 1 },
  {
    received(data) {
      const messages = document.getElementById("messages")
      messages.innerHTML += `<p><strong>${data.user}:</strong> ${data.message}</p>`
    }
  }
)

// Send a message

document.getElementById("send").addEventListener("click", () => {
  const input = document.getElementById("message-input")
  chat.perform("speak", { message: input.value })
  input.value = ""
})

Turbo Streams Integration

SolidCable works seamlessly with Turbo Streams:

# app/models/comment.rb


class Comment < ApplicationRecord
  belongs_to :post
  belongs_to :user

  after_create_commit -> { broadcast_append_to post }
  after_update_commit -> { broadcast_replace_to post }
  after_destroy_commit -> { broadcast_remove_to post }
end
<%# app/views/posts/show.html.erb %>

<%= turbo_stream_from @post %>

<div id="comments">
  <%= render @post.comments %>
</div>

<%= form_with model: [@post, Comment.new] do |f| %>
  <%= f.text_area :content %>
  <%= f.submit "Add Comment" %>
<% end %>

Message Trimming

SolidCable automatically trims old messages. Configure retention and trimming behavior:

# config/cable.yml



production:

  adapter: solid_cable

  message_retention: 1.hour

  autotrim: true

For high volume applications, disable autotrim and run cleanup in a background job:

# config/cable.yml



production:

  adapter: solid_cable

  message_retention: 1.hour

  autotrim: false
# config/recurring.yml



production:

  trim_cable_messages:

    class: SolidCable::TrimJob

    schedule: "*/5 * * * *"

Single Database Configuration

For simpler deployments, use a single database:

  1. Copy the schema into a migration:
# db/migrate/20251226000000_create_solid_cable_tables.rb


class CreateSolidCableTables < ActiveRecord::Migration[8.0]
  def change
    create_table :solid_cable_messages do |t|
      t.binary :channel, null: false, limit: 1024
      t.binary :payload, null: false, limit: 536870912
      t.datetime :created_at, null: false
      t.index [:channel, :created_at]
      t.index :created_at
    end
  end
end
  1. Update cable configuration:
# config/cable.yml



production:

  adapter: solid_cable

  polling_interval: 0.1.seconds

  message_retention: 1.day
  1. Run the migration:
bin/rails db:migrate

Caveats and Considerations

Latency

SolidCable uses polling, which introduces latency. With the default 0.1 second polling interval, messages can take up to 100 milliseconds to arrive.

For most applications, this is acceptable. For latency sensitive features like gaming or live trading, consider Redis or AnyCable.

Reduce polling interval for lower latency:

# config/cable.yml



production:

  adapter: solid_cable

  polling_interval: 0.01.seconds

Lower intervals increase database load. Test thoroughly before deploying.

Database Load

Every connected client polls the database. With many concurrent connections, this creates significant read load.

Monitor the database and scale accordingly. Consider:

  • Using a dedicated cable database
  • Increasing connection pool size
  • Using read replicas for polling queries

Connection Limits

Each WebSocket connection uses a database connection for polling. Ensure the database can handle the connection count:

# config/database.yml



production:

  cable:

    pool: <%= ENV.fetch("CABLE_DB_POOL", 50) %>

Message Size Limits

SolidCable stores messages in the database. Very large messages can impact performance. Keep broadcast payloads small:

# Avoid this

ActionCable.server.broadcast("channel", { data: huge_object.to_json })

# Do this instead

ActionCable.server.broadcast("channel", { id: record.id, type: "update" })

Let clients fetch full data via HTTP if needed.

No Presence Tracking

Unlike some Redis based solutions, SolidCable does not provide built in presence tracking. Implement it manually if needed:

# app/channels/presence_channel.rb


class PresenceChannel < ApplicationCable::Channel
  def subscribed
    @room_id = params[:room_id]
    stream_from "presence_#{@room_id}"

    PresenceTracker.join(@room_id, current_user)
    broadcast_presence
  end

  def unsubscribed
    PresenceTracker.leave(@room_id, current_user)
    broadcast_presence
  end

  private

  def broadcast_presence
    users = PresenceTracker.users_in(@room_id)
    ActionCable.server.broadcast(
      "presence_#{@room_id}",
      { users: users.map { |u| { id: u.id, name: u.name } } }
    )
  end
end
# app/services/presence_tracker.rb


class PresenceTracker
  def self.join(room_id, user)
    Rails.cache.write(
      "presence:#{room_id}:#{user.id}",
      true,
      expires_in: 5.minutes
    )
  end

  def self.leave(room_id, user)
    Rails.cache.delete("presence:#{room_id}:#{user.id}")
  end

  def self.users_in(room_id)
    # Implementation depends on the caching strategy

  end
end

Horizontal Scaling

SolidCable works across multiple servers because all servers read from the same database. However, ensure the database can handle the combined load.

PostgreSQL LISTEN/NOTIFY

When using PostgreSQL, SolidCable can use LISTEN/NOTIFY for more efficient message delivery. This reduces polling overhead but requires PostgreSQL specific configuration.

Performance Benchmarks

These benchmarks compare the SolidCable adapter against the Redis adapter within ActionCable — they measure message delivery latency at different concurrent connection counts. It is worth noting that ActionCable itself has inherent concurrency limits that apply regardless of which backend adapter is used. At very high connection counts, the bottleneck shifts to ActionCable, not to SolidCable or Redis.

With SQLite:

  • 100 concurrent connections: Comparable to Redis
  • 250 concurrent connections: Slight latency increase
  • 500 concurrent connections: Noticeable latency, still functional
  • 750 concurrent connections: Consider Redis or AnyCable

With PostgreSQL (using LISTEN/NOTIFY):

  • 100 concurrent connections: Comparable to Redis
  • 500 concurrent connections: Comparable to Redis
  • 1000 concurrent connections: Slight latency increase
  • 2000 concurrent connections: Consider AnyCable for this scale

PostgreSQL with LISTEN/NOTIFY performs significantly better at higher connection counts due to push-based notifications instead of polling. The gap between SolidCable and Redis narrows considerably when using the PostgreSQL adapter.

When to Use SolidCable

SolidCable is ideal when:

  • We want to eliminate Redis dependency
  • Latency requirements are not extreme
  • We prefer simpler infrastructure

Consider alternatives when:

  • We need sub 10 millisecond message delivery
  • We already use Redis for other features

For very high concurrency (thousands of connections), neither SolidCable nor the Redis adapter will help much. ActionCable itself becomes the bottleneck at that scale. Consider AnyCable for such use cases, which offloads WebSocket handling to a Go server for significantly better performance and lower memory usage.

Migration from Redis

Migrating from Redis adapter is straightforward:

  1. Install SolidCable
  2. Configure the cable database
  3. Update config/cable.yml
  4. Deploy
# Before

production:

  adapter: redis

  url: <%= ENV["REDIS_URL"] %>


# After

production:

  adapter: solid_cable

  connects_to:

    database:

      writing: cable

  polling_interval: 0.1.seconds

Existing WebSocket connections will disconnect during deployment. Clients will automatically reconnect.

Conclusion

SolidCable brings database backed WebSocket support to Rails 8, eliminating Redis as a requirement for real time features. It integrates seamlessly with ActionCable and Turbo Streams, making it easy to add real time functionality to the application.

The polling based approach introduces some latency compared to Redis pub/sub, but for most applications, the tradeoff is worthwhile. Simpler infrastructure means fewer things to manage and monitor.

Check the official SolidCable repository for the latest documentation and benchmarks.

Need expert help with Rails database work?

Saeloun is a Rails Foundation Contributing Member helping teams modernize, upgrade, scale, and maintain production Rails applications.

Our Expertise

  • Rails contributors
  • 500+ Technical Articles
  • Production Rails consulting
  • Performance Optimization

Services

  • Rails application development
  • Code Audits
  • Rails upgrades
  • Team Augmentation

Need help on your Ruby on Rails or React project?

Join Our Newsletter