Real-time notifications transform static web applications into dynamic, engaging experiences. Users expect instant feedback when something relevant happens—a new message arrives, an order ships, or a teammate comments on their work. Rails 8 makes building this functionality remarkably straightforward by combining Turbo Streams for live DOM updates with Solid Queue for reliable background processing.
The Architecture: How the Pieces Fit Together
A robust notification system requires three components working in harmony:
- Notification model – stores notification data and tracks read/unread state
- Background job – creates notifications asynchronously to avoid blocking user requests
- Turbo Stream broadcast – pushes new notifications to connected browsers instantly
This architecture ensures that notification creation never slows down the triggering action (like posting a comment), while users still see updates appear in real-time without refreshing the page.
Setting Up the Notification Model
Start by generating a Notification model that tracks the recipient, the source of the notification, and whether the user has seen it:
# Terminal
rails generate model Notification \
recipient:references{polymorphic}:index \
actor:references{polymorphic}:index \
notifiable:references{polymorphic}:index \
action:string \
read_at:datetimeThe polymorphic associations provide flexibility—notifications can reference any model as the recipient (usually a User), actor (who triggered it), and notifiable (what it's about). Configure the model with scopes and broadcasting:
# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :recipient, polymorphic: true
belongs_to :actor, polymorphic: true, optional: true
belongs_to :notifiable, polymorphic: true, optional: true
scope :unread, -> { where(read_at: nil) }
scope :recent, -> { order(created_at: :desc).limit(20) }
after_create_commit -> { broadcast_prepend_to(stream_name, target: "notifications") }
after_update_commit -> { broadcast_replace_to(stream_name) }
after_destroy_commit -> { broadcast_remove_to(stream_name) }
def read?
read_at.present?
end
def mark_as_read!
update!(read_at: Time.current) unless read?
end
private
def stream_name
"notifications_#{recipient.to_gid_param}"
end
endThe stream_name method generates a unique channel for each recipient. Using to_gid_param creates a globally unique identifier that works across different recipient types if the application ever needs to notify teams or organizations in addition to users.
Creating Notifications with Solid Queue
Rails 8 ships with Solid Queue as the default Active Job backend. Configure it to handle notification delivery:
# config/environments/production.rb
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }Create a job that handles the notification creation logic:
# app/jobs/notification_job.rb
class NotificationJob < ApplicationJob
queue_as :default
def perform(recipient:, actor: nil, notifiable: nil, action:)
return if recipient == actor # Don't notify users of their own actions
Notification.create!(
recipient: recipient,
actor: actor,
notifiable: notifiable,
action: action
)
end
endNow trigger notifications from anywhere in the application without blocking the request:
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
belongs_to :author, class_name: "User"
after_create_commit :notify_post_author
private
def notify_post_author
NotificationJob.perform_later(
recipient: post.author,
actor: author,
notifiable: self,
action: "commented_on_post"
)
end
endBuilding the Real-Time Frontend
Create a notifications controller to display and manage notifications:
# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
before_action :authenticate_user!
def index
@notifications = current_user.notifications.recent.includes(:actor, :notifiable)
end
def mark_as_read
@notification = current_user.notifications.find(params[:id])
@notification.mark_as_read!
respond_to do |format|
format.turbo_stream
format.html { redirect_to notifications_path }
end
end
def mark_all_as_read
current_user.notifications.unread.update_all(read_at: Time.current)
redirect_to notifications_path, notice: "All notifications marked as read"
end
endThe index view subscribes to the user's notification stream and renders existing notifications:
# app/views/notifications/index.html.erb
<%= turbo_stream_from "notifications_#{current_user.to_gid_param}" %>
Notifications
<%= link_to "Mark all as read", mark_all_as_read_notifications_path,
method: :post, class: "btn btn-secondary" %>
<%= render @notifications %>
Each notification renders as a partial that Turbo Streams can target individually:
# app/views/notifications/_notification.html.erb
<%= turbo_frame_tag dom_id(notification) do %>
<% if notification.actor %>
<%= notification.actor.name %>
<% end %>
<%= render "notifications/actions/#{notification.action}", notification: notification %>
<% end %>Create action-specific partials for different notification types:
# app/views/notifications/actions/_commented_on_post.html.erb
commented on your post
<%= link_to notification.notifiable.post.title,
post_path(notification.notifiable.post, anchor: dom_id(notification.notifiable)) %>
Adding a Notification Counter with Stimulus
A notification bell in the navigation bar needs to update its counter in real-time. Create a Stimulus controller that listens for DOM changes:
// app/javascript/controllers/notification_counter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["count", "badge"]
static values = { count: Number }
connect() {
this.observeNotifications()
}
observeNotifications() {
const container = document.getElementById("notifications")
if (!container) return
this.observer = new MutationObserver(() => this.updateCount())
this.observer.observe(container, { childList: true, subtree: true })
}
updateCount() {
const unreadCount = document.querySelectorAll(".notification.unread").length
this.countValue = unreadCount
this.countTarget.textContent = unreadCount
this.badgeTarget.classList.toggle("hidden", unreadCount === 0)
}
disconnect() {
this.observer?.disconnect()
}
}Wire it up in the navigation:
# app/views/layouts/_navbar.html.erb
<%= link_to notifications_path, class: "nav-link" do %>
<%= current_user.notifications.unread.count %>
<% end %>
Handling Edge Cases
Several scenarios require careful handling:
- Duplicate notifications – Add a unique constraint or check before creation to prevent spamming users with identical notifications
- Deleted notifiables – The
optional: trueon the notifiable association allows notifications to persist even when the referenced record is deleted. Handle nil notifiables gracefully in views. - High-volume scenarios – For applications with heavy notification traffic, consider batching broadcasts or implementing notification digests for less time-sensitive updates
Add database indexes to keep queries fast as the notifications table grows:
# db/migrate/XXXXXX_add_notification_indexes.rb
class AddNotificationIndexes < ActiveRecord::Migration[8.0]
def change
add_index :notifications, [:recipient_type, :recipient_id, :read_at],
name: "index_notifications_on_recipient_and_read_status"
add_index :notifications, [:recipient_type, :recipient_id, :created_at],
name: "index_notifications_on_recipient_and_created"
end
endSummary and Next Steps
This implementation delivers a production-ready notification system with minimal dependencies—everything used ships with Rails 8 by default. Turbo Streams handle the real-time updates over WebSockets, Solid Queue processes notification creation in the background, and Stimulus adds the interactive counter behavior.
To extend this foundation, consider adding notification preferences (allowing users to opt out of certain notification types), email delivery for important notifications, or push notifications for mobile browsers. The polymorphic design makes it straightforward to add new notification types as application features grow.