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.
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:
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:
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:
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:
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_fromCurrent.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:
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.transactiondoorder.update!(state: "paid")Notifications::Deliver.new(recipient: user,message: "Your order is paid").callBilling::Charge.new(order).call# raisesend
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.
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:
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.
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::Notificationsmeans something else entirely: the instrumentation API, whereActiveSupport::Notifications.instrumentpublishes events likesql.active_recordandprocess_action.action_controllerand 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-rails2.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 byrails newis Solid Cable, which means Postgres. The generator is explicit about this:railtiesrunssolid_cache:install solid_queue:install solid_cable:installunless you pass--skip-solid, and thesolid_cable:installgenerator overwritesconfig/cable.ymlwith 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
read_atis both the flag and the timestamp:NULLmeans unread, and a value means read at that moment. A boolean would have answered half as many questions for the same byte count.urlis nullable because a notification does not have to lead anywhere. The partial branches on it and renders alink_towhen it is present and a<p>when it is not, which is why the model validates onlymessagefor presence.The model itself is eighteen lines below its schema annotation, and most of them are scopes:
The
unless read?guard inmark_as_read!is worth the line. Without it, a second click on the same button rewritesread_atandupdated_atand 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: truemeans anything can be notified. ATeam, anOrganizationor anApiKeygains notifications by declaringhas_many :notifications, as: :recipient, dependent: :destroy, which is the line theUsermodel 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_idpoints at whichever tablerecipient_typenames, sodb/schema.rbin the product lists thirteenadd_foreign_keylines and not one of them is fornotifications. Delete a user through anything that is not the association, a rawDELETE, adelete_all, a fixture reset, and the notifications survive their recipient with no error anywhere.dependent: :destroyis the entire integrity story.The second cost is paid per row and per index:
recipient_typeis 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 :userwith 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 fromuser_idto 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: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 ownadd_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:
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, increated_atorder. 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:
The broadcast half is two calls, because two parts of the page change and they are not adjacent:
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_toreachesbroadcast_stream_to, which callsActionCable.server.broadcaston the spot, so the partial is rendered inside whichever request or job calledDeliver. The alternative,broadcast_prepend_later_to, enqueuesTurbo::Streams::ActionBroadcastJoband 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_fromruns its streamables throughTurbo::StreamsChannel.signed_stream_name, which builds the name withstream_name_from: each streamable becomesto_gid_paramorto_param, and an array is joined with a colon.to_gid_paramis the unpadded urlsafe Base64 of the GlobalID, so the user with id 7 subscribes toZ2lkOi8vbGF1bmNoa2l0L1VzZXIvNw:notifications, which decodes togid://launchkit/User/7.Turbo.signed_stream_verifierthen signs that whole string before it is written into the<turbo-cable-stream-source>tag, andTurbo::StreamsChannel#subscribedrejects any subscription whoseverified_stream_name_from_paramscomes 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*streamso the pair is what gets hashed, exactly as the view built it. The comment overstates the splat slightly, sincebroadcast_stream_tocallsstreamables.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 withauthenticated? && 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:installtemplate unedited:A broadcast becomes
SolidCable::Message.broadcast, which is aninsertof the channel, the payload and achannel_hashcomputed asDigest::SHA256.digest(channel.to_s).unpack1("q>"), an integer because Postgres has no unsigned types. In every Puma process, one thread namedsolid_cable_listenerloops: read the messages newer than the last id seen on the channels this process has subscribers for, hand them to the subscriber map, sleeppolling_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.daybounds it, and the trimming is opportunistic,SolidCable::TrimJobrunning inline after a broadcast with atrim_chanceof 2 against atrim_batch_sizeof 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 generatedconfig/cable.ymlexplains the consequence and it is worth reading before you debug it: a broadcast triggered frombin/rails consolein 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:
create!has not committed whenbroadcast_prepend_toruns.SolidCable::Recorddeclaresconnects_to(**SolidCable.connects_to), which in production is thecabledatabase, a separate database inconfig/database.ymlwith 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, becauseCurrent.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#completedelivers afterCurrent.user.update!, andReferrals::Convertdelivers afterreferral.update!, so the bug is absent today and is onetransaction doaway. The general fix is the one the Turbo docs reach for first: broadcast fromafter_create_commiton the model, so the callback runs when the database says the row is real. Moving the call after theendof 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_toand assuming the job inherits the transaction.ActiveJob::Base.enqueue_after_transaction_commitdefaults tofalsein Rails 8.1, and Solid Queue here writes to its ownqueuedatabase 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#indexassignsCurrent.user.notifications.recentand 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 aGETinto 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:
updatemarks one, throughCurrent.user.notifications.find(params[:id]).mark_as_read!. Scoping through the association rather thanNotification.findis 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_foundandother.reloadis still unread.read_allclears the badge in one click withCurrent.user.notifications.unread.update_all(read_at: Time.current).update_allis oneUPDATEfor any number of rows, and it skips validations, callbacks andupdated_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 theupdate_allwould 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.erbrenders its empty line with a class computed once, when the page is rendered:A live notification prepends into
#notificationsand replaces#notifications_badge. Nothing inNotifications::Delivertouches#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::StreamsChanneland 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
#notificationsso 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::Deliverwrites 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
messagestring and an optionalurl, with notypeorcategorycolumn, 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.
Delivertakes 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.