Notifications in Rails
One table, a polymorphic recipient, and a service object that writes the row and then broadcasts it over Turbo Streams. In-app only, which is the half most products actually need, and the page says plainly where push to a locked phone starts.
Articles on this topic
-
Rails web push notifications, from VAPID to the 410 that deletes the row
Rails 8 generates the service worker file and comments every line of it out. What you add on top: a VAPID key pair, a subscriptions table, the web-push gem, and the four ways this breaks in production.
-
Rails notifications over Turbo Streams, and the broadcast that outruns the commit
In-app notifications in Rails 8 are a table of your own plus two Turbo Stream broadcasts, carried by Solid Cable over Postgres. The badge updates without the browser polling, and the broadcast still fires before the transaction commits.
-
Rails notifications without a gem
The noticed gem is two tables, a notifier class and one job per delivery method. The model here is one table, one polymorphic belongs_to and three scopes. The choice is decided by how many channels you deliver on, not by how much code you write.
A notification is the smallest feature in a SaaS that still touches five layers: a table, a service object, a partial, a socket and a badge in the topbar. Most of the difficulty is not in any one of them. It is in deciding how little to build, because the version with notification types, delivery channels, per-user preferences and a digest mailer is a month of work for a product that today needs to tell one person that their referral converted.
What Rails 8 already does, and the piece it leaves out
Rails ships the transport and none of the domain. Action Cable is in the framework, turbo-rails (2.0.23 in this codebase) gives you
turbo_stream_fromin a view andTurbo::StreamsChannel.broadcast_prepend_toin Ruby, and Rails 8 shipssolid_cable(4.0.2 here), which the generatedconfig/cable.ymlnames as the production adapter and which puts the pubsub messages in a database table instead of asking you to run Redis. What Rails does not ship is a notification: no model, no table, no controller, no concept of read or unread anywhere in the framework.So the decision is not whether to use a gem, it is what the record looks like. A notification that stores a rendered string is trivial to write and impossible to translate later. A notification that stores a type and a JSON payload renders per locale and per channel, and costs you a partial and a registry per type before the first one works.
config/cable.ymlis worth reading before the first bug report. Development uses theasyncadapter, which only works inside one process, so a broadcast triggered frombin/rails consolein a terminal goes nowhere and the browser you have open never blinks. The generated comment at the top of that file says so. Every developer meets this once and concludes the socket is broken.One table, one polymorphic recipient
Notificationcarries five columns of its own besideidand the timestamps:message,read_at,recipient_type,recipient_idandurl. The model is the whole domain:Read state is a nullable timestamp rather than a boolean, which costs nothing and buys the answer to "when did they see it". Unread is a scope over that column, not a counter cached on the user, so there is no second number to keep in step with the rows.
The polymorphic recipient is the choice that deserves an argument, because today it is always a
Userand a plainbelongs_to :userwould be shorter and would give you a real foreign key.recipient_typeis a string column with no constraint behind it: nothing in the database stops a row naming a class that no longer exists. What it buys is the day aTeamor anOrganizationhas to be notified, which for a boilerplate somebody else will extend is the likelier future. The index is built for the query that actually runs,(recipient_type, recipient_id, read_at), because the unread count is read on every authenticated page render from the topbar partial. Migrations name that one for you and the result isidx_on_recipient_type_recipient_id_read_at_50191a301d, which is ugly and is the index that keepsCurrent.user.notifications.unread.countfrom turning into a sequential scan on the hottest read path in the application. The count is a database query per page either way. Caching it on the user row would remove that query and hand you a number that has to be corrected every time a notification is created, read, or destroyed with the user, which is three places to be wrong in exchange for one indexed count.Notifications without a gem is where that table gets compared to what a notifications gem gives you instead, and where the case for adding one later, once there are delivery channels and a dozen types, is made honestly rather than dismissed.
Notifications::Deliver, the single entry point
Nothing in the application calls
Notification.create!directly. Two callers exist today, the onboarding controller'scompleteaction andReferrals::Convert, and both go through one service:The service writes the row, then broadcasts twice: a prepend of the
notifications/notificationpartial into thenotificationstarget, and a replace ofnotifications/badgeinto thenotifications_badgetarget. Both broadcasts use the splatted stream[recipient, :notifications], which has to match theturbo_stream_from Current.user, :notificationsin the layout exactly, and a mismatch here is silent in both directions: no error, no log line, just a page that never updates.broadcast_prepend_tois the synchronous form. The partial is rendered in the web process, inside the request that created the notification, before the response is sent.broadcast_prepend_later_toexists and pushes that render into an Active Job instead. The synchronous version is right while the partial is cheap and there is one recipient, and it is wrong the moment you notify two hundred people in a loop, because that is two hundred view renders on a request the user is waiting for.How the Turbo Stream broadcast works takes that path apart: the stream name, the two targets, what Action Cable does with the frame, and what the reader sees when the socket is not connected at all.
Two targets, and the one the page does not have
The badge and the list are updated separately because they live in different places. The badge is in
shared/_topbar.html.erband renders on every page a signed-in, onboarded user loads. The list is thenotificationsdiv, and that div exists only on/notifications. So on the dashboard, the prepend half of every delivery targets an element that is not in the document, Turbo applies it to nothing, and only the badge visibly changes. Nothing reports that, and nothing should: it is the correct outcome, arrived at by accident of markup.Two rough edges are worth knowing before a buyer finds them. The index page has a
notifications_emptyparagraph, hidden when the list is not empty, and no broadcast targets it, so a user sitting on an empty notifications page when their first one arrives sees the new row and the words "You have no notifications." at the same time, until the next load.And only creation broadcasts.
mark_as_read!and theread_allaction both write and thenredirect_to notifications_path, so the tab that clicked is correct because it re-rendered, while a second tab keeps its old badge until something loads there. The database is the truth in both cases, which is what makes those a cosmetic lag rather than a bug, and it is the same property that makes a dropped WebSocket survivable: a refresh is always right.The signed stream name is the whole authorization
Subscription security in Turbo Streams works through a signature, not a callback.
turbo_stream_fromcallsTurbo::StreamsChannel.signed_stream_name(streamables)and renders the result into a<turbo-cable-stream-source signed-stream-name="...">element. When the browser subscribes,Turbo::StreamsChannel#subscribedverifies that string and callsrejectwhen verification fails. There is no per-subscription authorization hook in the default channel, and this codebase does not add one.What follows from that is the rule to keep: the signed name for a user's stream must only ever be rendered into that user's page. The layout does that, with
turbo_stream_from Current.user, so the signature a browser holds is always its own. Put a stream name in a shared cache fragment or a public page and you have handed out a subscription that verifies perfectly.Underneath,
ApplicationCable::Connectionidentifies the socket withidentified_by :current_userand looks up theSessionrecord from the signedsession_idcookie, rejecting the connection when there is none. That gates who may open a socket at all. It does not gate which streams they may then subscribe to, which is the part people assume and is the reason the signature matters.In-app only, and push is a different system
Push to a device is not in this module, and no amount of reading the code will find it. There is no push gem in the
Gemfile, noPushSubscriptionmodel and no registered service worker. What exists is the stub Rails' own application generator leaves behind:app/views/pwa/service-worker.js, whose entire content is a commented-outpushlistener, and two commented routes inconfig/routes.rbfor the manifest and the service worker. Nothing serves either path.Saying that plainly matters more than it might seem, because "real-time notifications" in a feature list reads as "it will buzz my phone", and it does not. This module puts a row in a table and moves a badge in an open browser tab. A user with the tab closed learns about it the next time they visit.
Web push is a separate protocol stack, and three RFCs is the short version of why it is a separate article. Delivery to a browser's push service is RFC 8030, the payload encryption that keeps that service from reading the message is RFC 8291, and the application server identity that lets the service reject messages from anyone else is RFC 8292, better known as VAPID. Then the platform rules sit on top, of which Apple's is the sharpest: web push has worked on iOS since 16.4, and only for a site the user has added to the Home Screen as a web app, so the permission prompt is worth nothing until they have done that.
Adding web push notifications on top covers what that actually adds to this codebase: the subscription table, the key pair, the service worker that has to be served for real, and the delivery failures a background job has to handle when a subscription has expired.
What this module leaves to you
Email is not wired to any of it. The codebase has mailers for confirmation, password reset, support tickets and the email sequences, and a notification nobody logs in to see stays unread forever: "email it if still unread after an hour" is a job, a preference column and a template that does not exist here. Types are absent for the same reason, so a notification holds one string translated at the moment it was created and an admin screen cannot group them by kind.
NotificationsController#indexloadsCurrent.user.notifications.recentwith no limit, no pagination and no expiry job behind it. Clicking a notification'surldoes not mark it read either. The "Mark read" button is the only thing that does, and that is the first simplification most buyers undo.