LaunchKit

Rails notifications over Turbo Streams, and the broadcast that outruns the commit

September 22, 2026

A notification is two things at once: a row somebody can come back to tomorrow, and a badge that has to change while they are looking at the page. Rails 8 gives you most of the second one and none of the first.

Rails has no notification model, and ActiveSupport::Notifications is not it

Rails 8 notifications, in the sense the phrase is almost always typed, means the bell in the corner of an app and the number sitting on it. The framework's own ActiveSupport::Notifications means something else entirely: the instrumentation API, where ActiveSupport::Notifications.instrument publishes events like sql.active_record and process_action.action_controller and subscribers turn them into logs and metrics. Nothing in it concerns a user seeing a bell light up. The name collision is the most expensive thing about this subject, and it costs an hour before anyone notices they are reading the wrong page of the guides.

What Rails does hand you is the delivery half. Turbo Streams, from turbo-rails 2.0.23, let the server push a fragment of HTML into a named target on a page that is already open. Action Cable carries it, and in Rails 8 the Action Cable adapter installed by rails new is Solid Cable, which means Postgres. The generator is explicit about this: railties runs solid_cache:install solid_queue:install solid_cable:install unless you pass --skip-solid, and the solid_cable:install generator overwrites config/cable.yml with the database adapter.

The model, the table, the controller, the views and the decision about what a notification even belongs to are yours, or a gem's: building the model without one is the comparison against noticed, and it turns on how many channels you deliver on rather than on how much code you write. Everything below is the notifications feature of this boilerplate, read out of the product repository rather than described from memory.

The table is four columns and a polymorphic owner

create_table :notifications do |t|
  t.references :recipient, null: false, polymorphic: true
  t.string :message, null: false
  t.string :url
  t.datetime :read_at

  t.timestamps
end

read_at is both the flag and the timestamp: NULL means unread, and a value means read at that moment. A boolean would have answered half as many questions for the same byte count.

url is nullable because a notification does not have to lead anywhere. The partial branches on it and renders a link_to when it is present and a <p> when it is not, which is why the model validates only message for presence.

The model itself is eighteen lines below its schema annotation, and most of them are scopes:

scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
scope :recent, -> { order(created_at: :desc) }

def mark_as_read!
  update!(read_at: Time.current) unless read?
end

The unless read? guard in mark_as_read! is worth the line. Without it, a second click on the same button rewrites read_at and updated_at and moves the notification's read time forward, which is wrong twice: the data is wrong, and the write happened for nothing.

What the polymorphic recipient buys, and what it costs

belongs_to :recipient, polymorphic: true means anything can be notified. A Team, an Organization or an ApiKey gains notifications by declaring has_many :notifications, as: :recipient, dependent: :destroy, which is the line the User model carries today. The comment above the association in the product is honest about the state of that bet: "Polymorphic so anything can be notified; today it is always a User."

The cost is a foreign key you cannot have. Postgres constrains a column against one table, and recipient_id points at whichever table recipient_type names, so db/schema.rb in the product lists thirteen add_foreign_key lines and not one of them is for notifications. Delete a user through anything that is not the association, a raw DELETE, a delete_all, a fixture reset, and the notifications survive their recipient with no error anywhere. dependent: :destroy is the entire integrity story.

The second cost is paid per row and per index: recipient_type is a string, repeated on every notification, and it sits at the front of both indexes on the table.

The position worth taking: keep the polymorphic column when a second recipient type is genuinely plausible within a year, and use belongs_to :user with a real foreign key when it is not. A boilerplate sold to people whose products are not written yet is the case where the polymorphic version earns its keep, because the second recipient type is the buyer's, not ours. In an application you control, the migration from user_id to a polymorphic pair is an hour, and the missing foreign key is forever.

The index behind the unread badge, and the one that is redundant

Two indexes exist on notifications, and only one was written on purpose:

t.index ["recipient_type", "recipient_id", "read_at"], name: "idx_on_recipient_type_recipient_id_read_at_50191a301d"
t.index ["recipient_type", "recipient_id"], name: "index_notifications_on_recipient"

The two column one comes free with t.references ... polymorphic: true, which indexes the pair by default. The three column one is the migration's own add_index, and it carries that generated name because the natural one, index_notifications_on_recipient_type_and_recipient_id_and_read_at, is 66 characters, past the 63 byte limit Postgres puts on an identifier.

A B-tree on (a, b, c) serves every query that a B-tree on (a, b) serves, so the shorter index is redundant for reads. What it is not is free: it is a second index to maintain on every insert, and every notification delivered pays for it. Dropping it is a one line migration nobody has written.

The query it all exists for is the badge, and the badge is counted on every single page render:

<%= render "notifications/badge", count: Current.user.notifications.unread.count %>

That is WHERE recipient_type = 'User' AND recipient_id = ? AND read_at IS NULL, which the three column index answers without touching the table. A partial index, WHERE read_at IS NULL, would be smaller still, because a notification leaves it the moment it is read and the unread set of a healthy account is a handful of rows. What a partial index will not serve is the index page, which reads every notification for the user, read and unread, in created_at order. The plain composite serves both queries at the cost of indexing rows nobody will filter on again. For a table this size that is the right trade, and it stops being the right trade at the point where read notifications outnumber unread ones by a few orders of magnitude and the count starts showing up in slow query logs.

One service creates the row and sends two broadcasts

Every notification in the product goes through one class, so there is one place where the shape of a notification is decided:

def call
  notification = recipient.notifications.create!(message: message, url: url)
  broadcast(notification)
  notification
end

The broadcast half is two calls, because two parts of the page change and they are not adjacent:

stream = [ recipient, :notifications ]

Turbo::StreamsChannel.broadcast_prepend_to(
  *stream, target: "notifications",
  partial: "notifications/notification", locals: { notification: notification }
)

Turbo::StreamsChannel.broadcast_replace_to(
  *stream, target: "notifications_badge",
  partial: "notifications/badge", locals: { count: recipient.notifications.unread.count }
)

The first prepends a rendered row into <div id="notifications"> on the index page. The second replaces <span id="notifications_badge"> in the top bar, which exists on every page of the signed in shell. A single broadcast cannot do both jobs, because a Turbo Stream action names one target.

Both of these are synchronous. broadcast_prepend_to reaches broadcast_stream_to, which calls ActionCable.server.broadcast on the spot, so the partial is rendered inside whichever request or job called Deliver. The alternative, broadcast_prepend_later_to, enqueues Turbo::Streams::ActionBroadcastJob and renders in the worker. Rendering two partials inline is cheap here and it keeps the failure in the request that caused it; a notification fanned out to hundreds of recipients is the case where the _later_ variants start to matter, and this codebase does not have that case.

The stream name is a GlobalID, and the page carries it signed

Nothing in the view names a channel class:

<%= turbo_stream_from Current.user, :notifications %>

turbo_stream_from runs its streamables through Turbo::StreamsChannel.signed_stream_name, which builds the name with stream_name_from: each streamable becomes to_gid_param or to_param, and an array is joined with a colon. to_gid_param is the unpadded urlsafe Base64 of the GlobalID, so the user with id 7 subscribes to Z2lkOi8vbGF1bmNoa2l0L1VzZXIvNw:notifications, which decodes to gid://launchkit/User/7. Turbo.signed_stream_verifier then signs that whole string before it is written into the <turbo-cable-stream-source> tag, and Turbo::StreamsChannel#subscribed rejects any subscription whose verified_stream_name_from_params comes back nil. Base64 is not the protection there. The signature is, which is why swapping the encoded id in devtools gets you nothing.

Naming the same two streamables on the broadcasting side is what makes delivery work, and the product spells that out with a comment on the splat: the service builds [ recipient, :notifications ] and passes *stream so the pair is what gets hashed, exactly as the view built it. The comment overstates the splat slightly, since broadcast_stream_to calls streamables.flatten! before naming the stream and the unsplatted array lands on the same name. What has to agree is the pair itself, in that order, on both sides. Get it wrong and nothing raises. The broadcast goes to a stream nobody listens to, the record is in the database, the page looks fine after a reload, and the only symptom is that the badge does not move until somebody navigates.

Where the subscription lives is a design decision rather than a detail. This one is in app/views/layouts/application.html.erb, inside the signed in shell, which is why the badge is live on the dashboard, the billing page and every other page rather than only on /notifications. The layout guards it with authenticated? && Current.user.onboarded?, so a user still walking the onboarding flow has no subscription at all. The welcome notification delivered at the end of that flow therefore broadcasts to an empty stream, and the badge the user sees a moment later on the dashboard is the database count, not the broadcast.

Solid Cable is a table and a polling thread

"No poll" is true of the browser and false of the system. Here is the whole production configuration, which is the solid_cable:install template unedited:

production:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

A broadcast becomes SolidCable::Message.broadcast, which is an insert of the channel, the payload and a channel_hash computed as Digest::SHA256.digest(channel.to_s).unpack1("q>"), an integer because Postgres has no unsigned types. In every Puma process, one thread named solid_cable_listener loops: read the messages newer than the last id seen on the channels this process has subscribers for, hand them to the subscriber map, sleep polling_interval, repeat.

So the polling did not disappear. It moved from N browsers hitting a Rails endpoint to one thread per process hitting one indexed table every 100 milliseconds, which is the trade that makes the feature affordable without Redis. Two consequences follow from it. Delivery latency has a floor of roughly the polling interval, which nobody perceives at 0.1 seconds and everybody perceives if somebody sets it to five seconds to save queries. And the messages table grows with every broadcast: message_retention: 1.day bounds it, and the trimming is opportunistic, SolidCable::TrimJob running inline after a broadcast with a trim_chance of 2 against a trim_batch_size of 100, which works out to roughly one broadcast in fifty deleting up to 100 expired rows.

Development uses adapter: async, which is in process only. The comment at the top of the generated config/cable.yml explains the consequence and it is worth reading before you debug it: a broadcast triggered from bin/rails console in a terminal will never appear in your browser, because that console is a different process from the one holding the WebSocket.

The broadcast that outruns the commit

Wrapping the delivery in a transaction is the bug that looks like correctness:

ActiveRecord::Base.transaction do
  order.update!(state: "paid")
  Notifications::Deliver.new(recipient: user, message: "Your order is paid").call
  Billing::Charge.new(order).call # raises
end

create! has not committed when broadcast_prepend_to runs. SolidCable::Record declares connects_to(**SolidCable.connects_to), which in production is the cable database, a separate database in config/database.yml with its own migrations path. The message insert therefore happens on a different connection, outside the primary transaction, and commits immediately. The raise rolls the order and the notification back, and the browser has already prepended a row for a notification that does not exist. The user reaches for the "Mark as read" button on it and gets a 404, because Current.user.notifications.find(params[:id]) has nothing to find. A reload makes the row disappear, which is the kind of report that arrives as "the notification flashed and vanished" and gets closed as unreproducible.

Redis behaves the same way, so this is not a Solid Cable property. Any pub/sub outside the database transaction is a message that cannot be rolled back.

Neither call site in this codebase is inside a transaction. OnboardingController#complete delivers after Current.user.update!, and Referrals::Convert delivers after referral.update!, so the bug is absent today and is one transaction do away. The general fix is the one the Turbo docs reach for first: broadcast from after_create_commit on the model, so the callback runs when the database says the row is real. Moving the call after the end of the block does the same job when the delivery is not a model concern.

What does not fix it is switching to broadcast_prepend_later_to and assuming the job inherits the transaction. ActiveJob::Base.enqueue_after_transaction_commit defaults to false in Rails 8.1, and Solid Queue here writes to its own queue database anyway, so the job row commits on a separate connection exactly like the cable message does.

Marking as read without writing on every page view

NotificationsController#index assigns Current.user.notifications.recent and does nothing else. Rendering the list marks nothing, which is the decision most in-app notification systems get wrong in the other direction: marking everything read on render turns a GET into a write, fires on browser prefetch and on every accidental back navigation, and makes the busiest page in the app one that cannot be cached or retried.

Marking read is explicit, and it is two routes:

resources :notifications, only: %i[index update] do
  patch :read_all, on: :collection
end

update marks one, through Current.user.notifications.find(params[:id]).mark_as_read!. Scoping through the association rather than Notification.find is what makes another user's id a 404 rather than a leak, and the request spec asserts exactly that: patch notification_path(other) answers :not_found and other.reload is still unread.

read_all clears the badge in one click with Current.user.notifications.unread.update_all(read_at: Time.current). update_all is one UPDATE for any number of rows, and it skips validations, callbacks and updated_at. It also skips the broadcast, which is the visible cost: clear your notifications in one tab and a second open tab keeps its old badge count until something re-renders it. A broadcast of the fresh count after the update_all would close that hole, and it is not written.

The price of the explicit design is that an unread badge stays lit until somebody deliberately clears it, which a certain kind of user reports as a bug. That is the correct trade for a notification you might actually need to act on, and the wrong one for a feed nobody reads, which is why the choice belongs to the product rather than to the framework.

The empty state that stays on screen

app/views/notifications/index.html.erb renders its empty line with a class computed once, when the page is rendered:

<p id="notifications_empty" class="mt-6 text-gray-500 <%= "hidden" if @notifications.any? %>">
  <%= t("notifications.index.empty") %>
</p>

A live notification prepends into #notifications and replaces #notifications_badge. Nothing in Notifications::Deliver touches #notifications_empty. So a user sitting on an empty notifications page when a notification arrives sees the new row appear directly above the sentence "You have no notifications.", and the contradiction stays there until they reload.

Both test suites stay green through this. The request spec drives HTTP and never opens a WebSocket, so it only ever sees server rendered pages where the class was computed correctly. The service spec stubs Turbo::StreamsChannel and asserts that the two broadcasts went out with the right target names, which they do. Nothing either spec can observe is wrong, and the defect exists only in the browser of somebody whose list was empty at the moment the page loaded, which is the state a developer with three seeded notifications is never in.

Fixing it costs one more broadcast or one fewer element: either replace the empty paragraph on delivery too, or move it inside #notifications so the prepend pushes it out of the way. Writing the fix is not the point of the section. Noticing that a real time UI has two sources of truth for the same screen, the server render and the broadcast, and that only one of them runs on any given update, is.

What this page does not cover

Email and push are not in it. Everything above is in app only: Notifications::Deliver writes a row and pushes HTML to open tabs, and a user who is not looking at the product learns nothing until they come back. Reaching them anyway means a separate delivery layer with its own retry and its own unsubscribe rules, whether that is Action Mailer or the browser push path, which needs a service worker, a VAPID key pair and a table of subscriptions before it delivers anything.

Preferences are not in it either. A notification is a message string and an optional url, with no type or category column, so there is nothing to mute per kind and nothing to group on. Adding that starts with a column and a constant, not with a settings page.

Fan out to many recipients is out of scope for what is written here. Deliver takes one recipient and does one insert and two synchronous renders, so an announcement to ten thousand users is ten thousand of each, in the request that triggered it. That workload wants a job, a bulk insert and one broadcast per connected recipient, and the streaming AI answers page is the one that works through broadcasting from a background job, including escaping content that did not come from your own templates.

More on Notifications in Rails

← All Notifications in Rails articles