Introduction
Action Cable channels are not just one-way pipes — clients can send messages to the server by invoking channel actions, and the server can respond or broadcast to others. This bidirectional messaging is what makes features like chat, typing indicators, and live forms possible.
Key Concepts
- Channel Actions: Public methods on a channel class that clients can invoke via
perform. They act like controller actions but for WebSocket messages. performMethod: The client-side method that sends a message to a specific channel action on the server.transmitMethod: Sends data only to the subscription that triggered the action, not to all subscribers on the stream.- Broadcasting: Sends data to all subscribers of a stream. Unlike
transmit, which targets one subscription, broadcasting reaches everyone listening.
Real World Context
In a chat application, when a user types a message and hits send, the client calls perform('speak', { body: 'Hello!' }). The server's speak action receives the message, saves it to the database, and broadcasts it to all subscribers in the room. Every connected user sees the new message instantly without any polling.
Deep Dive
To define a server action that clients can call, add a public method to your channel:
ruby# app/channels/chat_channel.rb class ChatChannel < ApplicationCable::Channel def subscribed @room = Room.find(params[:room_id]) stream_for @room end # Clients call this via perform('speak', { body: '...' }) def speak(data) message = @room.messages.create!( user: current_user, body: data["body"] ) ChatChannel.broadcast_to(@room, { user: current_user.username, body: message.body, created_at: message.created_at.iso8601 }) end def typing(data) # Broadcast typing indicator to everyone except the sender ChatChannel.broadcast_to(@room, { type: "typing", user: current_user.username, is_typing: data["is_typing"] }) end end
Notice how speak receives a data hash with the parameters sent by the client. The action persists the message and broadcasts it to all subscribers.
On the client side, calling these actions looks like this:
javascriptconst chatChannel = consumer.subscriptions.create( { channel: "ChatChannel", room_id: 42 }, { received(data) { if (data.type === "typing") { this.showTypingIndicator(data.user, data.is_typing) } else { this.appendMessage(data) } }, speak(body) { // perform sends the action name and data to the server this.perform('speak', { body: body }) }, startTyping() { this.perform('typing', { is_typing: true }) }, stopTyping() { this.perform('typing', { is_typing: false }) }, appendMessage(data) { const messages = document.getElementById('messages') messages.insertAdjacentHTML('beforeend', `<div class="message"><strong>${data.user}:</strong> ${data.body}</div>` ) }, showTypingIndicator(user, isTyping) { const indicator = document.getElementById('typing-indicator') indicator.textContent = isTyping ? `${user} is typing...` : '' } } )
The this.perform(action, data) call sends a message through the WebSocket to the named action on the server-side channel. The server processes it and can respond by broadcasting or transmitting.
If you need to respond to only the caller (not all subscribers), use transmit:
rubydef fetch_history(data) messages = @room.messages.order(created_at: :desc).limit(50) transmit(messages: messages.map { |m| { user: m.user.username, body: m.body } }) end
The transmit method sends data only to the subscription that invoked the action, making it ideal for queries that should not be broadcast to everyone.
Common Pitfalls
- Using
transmitwhen you meanbroadcast_to—transmitonly sends to the calling subscription. If you want all users in a chat room to see a new message, you must broadcast, not transmit. - Not handling different message types in
received— When a channel broadcasts multiple types of data (messages, typing indicators, presence updates), thereceivedcallback must differentiate between them, typically using atypefield. - Performing database writes without error handling — If
create!raises an exception in a channel action, the error is logged but the client gets no feedback. Wrap writes in error handling and transmit errors back to the caller.
Best Practices
- Use
broadcast_tooverbroadcast—broadcast_to(model, data)generates consistent stream names that matchstream_for. Using rawActionCable.server.broadcastwith hand-typed names is error-prone. - Add a
typefield to broadcast payloads — This lets the client-sidereceivedcallback route messages to the correct handler function. - Debounce typing indicators on the client — Sending a
typingaction on every keystroke floods the WebSocket. Debounce the call so it fires at most once every 500 milliseconds.
Summary
- Channel actions are public methods that clients invoke with
perform('action_name', data). broadcast_tosends data to all subscribers of a stream;transmitsends only to the calling subscription.- The client's
receivedcallback should route incoming data by type to separate handler functions. - Always validate and authorize data in channel actions, just as you would in controller actions.
- Debounce high-frequency client actions like typing indicators to avoid flooding the WebSocket.
Code Examples
class ChatChannel < ApplicationCable::Channel
def speak(data)
message = @room.messages.create!(
user: current_user,
body: data["body"]
)
ChatChannel.broadcast_to(@room, {
user: current_user.username,
body: message.body
})
end
end