Architecture
Understanding LiveCable's architecture helps you build better components and debug issues effectively.
High-Level Overview
Browser Server
┌─────────────────┐ ┌──────────────────────┐
│ Stimulus │ │ LiveCable │
│ Controller │◄─────►│ Component │
│ │ Cable │ │
│ - UI Events │ │ - State │
│ - DOM Updates │ │ - Business Logic │
└─────────────────┘ │ - Rendering │
└──────────────────────┘Component Lifecycle
1. Initial Render (Server-Side)
When a page loads:
User Request → Rails Controller → View renders `live` helper
→ Component instantiated
→ Component rendered to HTML
→ HTML sent to browserAt this stage, the component has no live connection yet. It's just plain HTML with Stimulus data attributes.
2. WebSocket Connection
When the page loads in the browser:
Stimulus Controller connects → ActionCable subscription created
→ Server creates or retrieves component
→ before_connect callbacks run
→ Channel attached, stream started
→ after_connect callbacks run
→ Component broadcasts current state3. User Interaction
When the user interacts with the component:
User clicks button → Stimulus dispatches action
→ ActionCable sends message to server
→ Server calls whitelisted action method
→ Action updates reactive variables
→ Container marks variables as dirty
→ before_render callbacks run
→ Component re-renders
→ after_render callbacks run
→ HTML broadcasted to client
→ morphdom updates DOM4. Stimulus Reconnection (Within a Page)
When Stimulus disconnects and reconnects a controller on the same page — for example during a parent component re-render that morphs the DOM, or when a list is sorted — the subscription is kept alive:
Stimulus controller disconnects → Subscription persists
→ Server-side component kept alive
Stimulus controller reconnects → Reuses existing subscription
→ Component already exists on server
→ Component broadcasts current state5. Turbo Navigation
When the user navigates to a new page with Turbo Drive:
User navigates away → Subscriptions for components not on new page are closed
→ Server-side components are disconnected and removed
→ WebSocket connection itself stays open
→ Components present on both pages keep their subscriptions
User navigates back → Page is freshly fetched from the server (not from cache)
→ Components are re-rendered in the HTTP response
→ Stimulus controllers connect and create new subscriptions
→ Server finds components from the HTTP render
→ Components broadcast current stateLiveCable adds <meta name="turbo-cache-control" content="no-cache"> to any page with live components, preventing Turbo from restoring a stale snapshot on back/forward navigation.
Core Components
LiveCable::Component
The base class for all live components.
Responsibilities:
- Define reactive variables and shared state
- Whitelist callable actions
- Implement business logic
- Render views
Key Methods:
reactive- Define reactive variablesactions- Whitelist action methodsbroadcast_render- Trigger a render and broadcast
LiveCable::Connection
Manages the lifecycle of components for a single WebSocket connection.
Responsibilities:
- Store component instances
- Manage containers (state storage)
- Route actions to components
- Coordinate rendering
Key Methods:
add_component- Register a new componentget/set- Read/write reactive variablesdirty- Mark variables as changedbroadcast_changeset- Render all dirty components
LiveCable::Container
Stores reactive variable values for a component.
Responsibilities:
- Store variable values
- Track which variables are dirty
- Wrap values in Delegators for change tracking
- Attach observers to track mutations
Key Features:
- Hash subclass for simple key-value storage
- Automatic wrapping of Arrays, Hashes, and ActiveRecord models
- Changeset tracking for efficient re-rendering
LiveCable::Delegator
Transparent proxy for Arrays, Hashes, and ActiveRecord models.
Responsibilities:
- Intercept mutating method calls
- Notify observers when changes occur
- Support nested structures
Example:
# When you do this:
items << 'new item'
# Behind the scenes:
delegator = Delegator::Array.new(['item1'])
delegator.add_live_cable_observer(observer, :items)
delegator << 'new item' # Calls observer.notify(:items)LiveCable::Observer
Notifies containers when delegated values change.
Responsibilities:
- Receive change notifications from Delegators
- Mark variables as dirty in containers
Change Tracking System
How Mutations Trigger Re-renders
Reactive variable is set:
rubyself.items = []Container wraps value in Delegator:
rubycontainer[:items] = Delegator.create_if_supported([], :items, observer)User mutates the value:
rubyitems << 'new item'Delegator notifies observer:
rubydef <<(value) result = super notify_observers result endObserver marks variable dirty:
rubydef notify(variable) container.mark_dirty(variable) endContainer adds to changeset:
rubydef mark_dirty(*variables) @changeset |= variables endAfter action completes, broadcast changeset:
rubydef broadcast_changeset components.each do |component| if container_changed? || shared_variables_changed? component.broadcast_render end end end
Subscription Persistence
Traditional ActionCable subscriptions are destroyed whenever a Stimulus controller disconnects. LiveCable keeps them alive across Stimulus disconnects that happen within the same page — for example when a parent component re-renders and morphs its children, or when a sortable list reorders its items.
Without Persistence (Standard ActionCable)
Stimulus disconnects → Subscription destroyed
Stimulus reconnects → New subscription → Full reconnection overheadWith Persistence (LiveCable)
Stimulus disconnects → Subscription persists → Server component kept alive
Stimulus reconnects → Reuses subscription → No reconnection overheadBenefits:
- No WebSocket churn during parent re-renders or DOM sorts
- No race conditions from rapid connect/disconnect cycles
- Server-side state survives within-page Stimulus reconnects
Turbo Drive navigations are handled separately. When navigating to a new page, subscriptions for components that do not appear on the new page are closed and their server-side instances removed. The underlying WebSocket connection stays open. Components that appear on both pages — such as a persistent nav widget — keep their subscriptions.
Implementation: The subscription manager tracks subscriptions by live_id. On each Turbo navigation it compares the current subscriptions against the incoming page's components and only closes those that are truly leaving.
Rendering Pipeline
LiveCable's rendering pipeline has two modes depending on whether you use .live.erb templates:
Standard Rendering (.html.erb)
Simple but less efficient:
- Component renders to complete HTML string
- Full HTML sent over WebSocket
- morphdom diffs against current DOM
- Changed elements are updated
This works fine but sends redundant static HTML on every update.
Partial Rendering (.live.erb)
Advanced and highly efficient: See Partial Rendering Guide for complete details.
How It Works
- Template compiled into parts at boot time
- Dependencies tracked using static analysis
- Only changed parts sent over WebSocket
- Client reconstructs HTML from partial updates
Performance: Up to 90% bandwidth reduction!
Child Component Optimization
The partial rendering system also solves the "double-render" problem with child components:
- Before: Child rendered in parent HTML, then re-rendered when its controller connected
- After: Child HTML included in parent's render result, reused when controller connects
- Result: No redundant renders!
morphdom Integration
Once HTML is assembled (from parts or full render), morphdom updates the DOM:
- New HTML created from parts or full render
- morphdom diffs against current DOM
- Only changed elements updated
- Event listeners and component state preserved
Special Attributes
live-ignore: Skip updating this element and its childrenlive-key: Identity hint for list items (preserves DOM elements during reordering)
Example:
<% items.each do |item| %>
<li live-key="<%= item.id %>">
<%= item.name %>
</li>
<% end %>When items are reordered, morphdom uses live-key to move existing elements instead of destroying and recreating them.
Performance Comparison
| Scenario | .html.erb | .live.erb |
|---|---|---|
| Initial render | 1 KB HTML | 1 KB (all parts) |
| Single variable change | 1 KB HTML | ~100 bytes (one part) |
| Template switch | 1 KB HTML | ~500 bytes (dynamic parts only) |
| 10 rapid changes | 10 KB | ~1 KB total |
For detailed information, see the Partial Rendering Guide.
Security
Action Whitelisting
Only explicitly declared actions can be called from the frontend:
actions :safe_method, :another_safe_method
def safe_method
# Callable from frontend
end
def internal_method
# Not callable - will raise error
endCSRF Protection
LiveCable includes CSRF token validation on all WebSocket messages:
- Token is embedded in the Stimulus controller
- Token is sent with every action
- Server validates token before processing
- Invalid tokens are rejected
Performance Considerations
Efficient Re-rendering
- Only components with dirty variables are re-rendered
- Changesets are reset after each broadcast cycle
- morphdom minimizes actual DOM manipulations
Memory Management
- Components are cleaned up when connections close
- Containers are destroyed when components are removed
- Observers are detached when values are replaced
Scalability
- Each WebSocket connection has its own component instances
- Shared variables use a single container per connection
- ActionCable handles WebSocket scaling natively
Debugging Tips
Enable ActionCable Logging
ActionCable has its own logging that can be enabled in development:
# config/environments/development.rb
config.action_cable.log_level = :debugInspect Component State
In the browser console, Stimulus controllers can be inspected via your application instance:
// Get all LiveCable controllers
application.controllers.filter(c => c.identifier === 'live')
// Get component data from a controller's element
controller.element.dataset.liveIdValue
controller.element.dataset.liveComponentValueAdd Render Logging
Add logging to your components using lifecycle callbacks:
after_render do
Rails.logger.debug("Rendered #{self.class.name}")
end