class CommentsChannel < ApplicationCable::Channel
def subscribed
post = find_post
if post
stream_for post
else
reject
end
end
def unsubscribed
stop_all_streams
end
private
def find_post
current_user.readable_posts.find_by(id: params[:post_id])
end
end
class Comment < ApplicationRecord
belongs_to :post
belongs_to :author, class_name: "User"
validates :body, presence: true, length: { maximum: 5_000 }
after_create_commit :broadcast_later
scope :recent, -> { order(created_at: :desc) }
private
def broadcast_later
CommentBroadcastJob.perform_later(id)
end
end
class CommentBroadcastJob < ApplicationJob
queue_as :default
def perform(comment_id)
comment = Comment.find_by(id: comment_id)
return if comment.nil?
CommentsChannel.broadcast_to(comment.post, {
id: comment.id,
post_id: comment.post_id,
html: render_comment(comment)
})
end
private
def render_comment(comment)
ApplicationController.renderer.render(
partial: "comments/comment",
locals: { comment: comment }
)
end
end
<article class="comment" id="comment_<%= comment.id %>" data-author-id="<%= comment.author_id %>">
<header class="comment__meta">
<span class="comment__author"><%= comment.author.display_name %></span>
<time datetime="<%= comment.created_at.iso8601 %>">
<%= time_ago_in_words(comment.created_at) %> ago
</time>
</header>
<div class="comment__body">
<%= simple_format(comment.body) %>
</div>
</article>
This snippet shows the standard Rails path for pushing a freshly created Comment to everyone watching the same discussion, in real time, over a WebSocket. The pieces are a channel that subscribers connect to, a model callback that enqueues work after commit, and a job that renders and broadcasts the payload.
In CommentsChannel, subscribed calls stream_for @post, which subscribes the connection to a stream whose name is derived from the Post record (via its GlobalID). Using stream_for a model rather than stream_from a hand-built string keeps naming consistent with broadcast_to, so the producer and consumer can never drift apart. The channel guards access by looking up the post through current_user.posts so a client cannot subscribe to a room it may not read; if that lookup fails the subscription is rejected. Client params are untrusted, so find doing the authorization check matters.
The Comment model avoids broadcasting inside the request cycle. after_create_commit is deliberate: it fires only after the database transaction commits, which guarantees the row is actually persisted and visible before any subscriber is told about it. Broadcasting from after_save or after_create risks emitting an event for a comment that a later rollback erases, leaving clients with a phantom message. The callback enqueues CommentBroadcastJob rather than broadcasting inline, moving HTML rendering and Redis publishing off the web thread.
In CommentBroadcastJob, the comment is reloaded defensively (it may have been destroyed between enqueue and execution), then rendered to a partial. broadcast_to targets the same post the channel streamed for, and ApplicationController.renderer produces the exact markup the browser expects, so the client can append the node without a round trip. Rendering server-side keeps a single source of truth for comment markup.
The trade-off is eventual delivery: the job runs asynchronously, so a subscriber sees the comment a moment after commit rather than instantly, and a dead queue means no delivery at all. For most collaborative UIs that latency is invisible and well worth keeping slow work out of the request. This pattern generalizes to any 'notify watchers of a record change' feature — presence, live counters, or activity feeds.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.