Real-Time Notifications in Rails 8

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:datetime

The 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
end

The 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
end

Now 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
end

Building 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
end

The 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 %>
<% unless notification.read? %> <%= button_to "Mark as read", mark_as_read_notification_path(notification), method: :patch, class: "btn-link" %> <% end %>
<% 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: true on 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
end

Summary 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.

10 claps
← Back to Blog