LaunchKit

Rails notifications without a gem

September 22, 2026

Searching for a Rails notifications gem returns one serious answer, and the honest question is not which gem but whether the feature you are about to build is the feature the gem is for. A bell in the topbar with a red count on it is one table. An event that has to reach email, Slack, an iOS device and the in-app list, each with its own queue and its own failure, is a different program, and writing that one by hand is how you end up maintaining a worse copy of somebody else's.

What the noticed gem puts in your database

The noticed gem installs db/migrate/20231215190233_create_noticed_tables.rb, which creates two tables rather than one. noticed_events holds a type string naming the notifier class, a polymorphic record reference, and a params jsonb column. noticed_notifications holds a type, a belongs_to :event, a polymorphic recipient, and two timestamps, read_at and seen_at. A later migration, 20240129184740_add_notifications_count_to_noticed_event.rb, adds a counter cache to the event.

The split is the interesting part, and it is not an accident of design taste. One comment posted to a thread with forty watchers is one row in noticed_events carrying the payload once, and forty rows in noticed_notifications carrying only a recipient and a read state. Noticed::Notification delegates params and record back to the event, so the forty rows never duplicate the data. A single-table design writes the payload forty times.

Noticed::Readable, included into the notification model, is where the query surface comes from: read, unread, seen, unseen as scopes, and mark_as_read, mark_as_seen and their bang and inverse forms as instance methods, plus mark_as_read_and_seen as a class method running update_all. Two states rather than one, because the gem distinguishes a notification that appeared on screen from one somebody opened.

What the gem does at delivery time

Noticed::Event is the class a notifier subclasses, and the delivery configuration is declarative:

class CommentNotifier < Noticed::Event
  deliver_by :action_cable do |config|
    config.channel = "NotificationChannel"
  end
  deliver_by :email do |config|
    config.mailer = "CommentMailer"
    config.wait = 5.minutes
  end
  bulk_deliver_by :slack do |config|
    config.url = Rails.application.credentials.dig(:slack, :webhook)
  end

  required_params :comment
end

lib/noticed/delivery_methods/ holds twelve individual methods: action_cable, action_push_native, discord, email, fcm, ios, microsoft_teams, slack, test, twilio_messaging, vonage_sms and webhook. Alongside them, lib/noticed/bulk_delivery_methods/ holds five that send once for the whole event rather than once per recipient: bluesky, discord, slack, test and webhook.

Noticed::EventJob is the fan-out. Calling CommentNotifier.with(comment: comment).deliver(users) inserts the recipient rows in one transaction with insert_all! and enqueues that single job, which then loops over every notification crossed with every delivery method and enqueues one job per pair. Forty recipients with three delivery methods is a hundred and twenty jobs, each independent, each retrying or failing on its own. Noticed::Deliverable::DeliverBy#computed_options reads wait, wait_until, queue and priority off the config for each one, and perform? runs a before_enqueue block where a throw :abort drops that delivery and no other.

Per-delivery-method configuration is real and it is the reason to take the gem. Per-delivery-method state is not stored anywhere: there is no third table recording that the Slack call succeeded and the email bounced. What happened to each delivery lives in your job backend, and nowhere else.

deliver_by :database does nothing now

Anyone arriving from a noticed 1.x tutorial writes deliver_by :database and gets a deprecation warning instead of an error:

The :database delivery method has been deprecated and does nothing. Notifiers automatically save to the database now.

The line is in Noticed::Deliverable.deliver_by, which returns early on that name. Persisting is no longer a delivery method because deliver inserts the recipient rows inside a transaction before EventJob is enqueued at all. The rows exist whether or not a single delivery job ever runs.

What Rails hands you before any gem does

Rails already answers most of a notifications schema, which is why the build-or-install question is closer than it looks. t.references :recipient, polymorphic: true writes the recipient_type and recipient_id pair and indexes them together by default. has_many :notifications, as: :recipient, dependent: :destroy on the User model is the other half of that association, and the dependent option is what keeps deleted accounts from leaving orphan rows behind.

Scopes are plain class methods returning relations, so unread and recent cost one line each and compose with everything else Active Record does. Turbo Streams, from turbo-rails, carries the real-time half: turbo_stream_from in the view and Turbo::StreamsChannel.broadcast_prepend_to in the application, with no channel class and no JavaScript written by you.

What Rails does not give you is the fan-out. Nothing in the framework says "this event goes to email and Slack and the browser, each with its own queue and its own retry policy", and building that yourself is where the hand-rolled version stops being small.

One table, one polymorphic belongs_to, three scopes

class Notification < ApplicationRecord
  # Polymorphic so anything can be notified; today it is always a User.
  belongs_to :recipient, polymorphic: true

  validates :message, presence: true

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

  def read?
    read_at.present?
  end

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

Eighteen lines of class body, and they are the whole model layer of the notifications feature: no concern, no base class, no callback, and nothing included beyond ApplicationRecord. The table under it is four columns and a pair: message (string, null: false), url (string, nullable), read_at (datetime, nullable), the polymorphic recipient, and timestamps. No type column, no params, no seen_at. A notification here is a sentence and an optional link, rendered by app/views/notifications/_notification.html.erb as either a link_to or a <p> depending on whether url is present.

Dropping seen_at is the decision worth naming, because it is the one that is annoying to reverse. Seen and read are two different facts: the badge going quiet when the list is opened, and the row going grey when the individual item is clicked. The version here has one timestamp and therefore one of those behaviours, and the bell count only falls when somebody presses a button.

Keeping belongs_to :recipient polymorphic while every row in production points at a User is the opposite decision, and it is close to free. The columns exist either way under t.references ... polymorphic: true, the index covers both, and the day an Organization or a Team needs a notification the model does not change at all.

The broadcast, and the splat that makes it land

Notifications::Deliver is the only way anything in the app creates a notification, and it does two things after the insert:

def broadcast(notification)
  # Splatted so the stream name matches the view's `turbo_stream_from recipient, :notifications`.
  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 }
  )
end

Two broadcasts because two things on screen are wrong after an insert: the list, which gains a row, and the badge, which is a number. The badge partial is replaced whole rather than incremented, which means the count is recomputed with recipient.notifications.unread.count on every delivery and is therefore correct even if a tab has been open since yesterday and missed three broadcasts.

The splat in that snippet is load-bearing. broadcast_prepend_to(*stream) signs the stream name from two arguments; broadcast_prepend_to(stream) would sign it from one array, produce a different signed name, and match nothing the browser is subscribed to. Nothing raises. The row simply never appears, and the only way to see the difference is to compare the signed value in the broadcast against the one in the page's <turbo-cable-stream-source> tag.

Both broadcasts run inline, in the request that triggered them, because Notifications::Deliver is a service object and not a job. The cost lands on OnboardingController#complete, which sends the welcome notification at the end of the onboarding flow, and on Referrals::Convert. Both are already slow paths doing several writes, so a render and a push cost little there. A notification sent from inside a hot request would want broadcast_prepend_later_to instead, which does the same work in a job.

One local detail that wastes an afternoon: config/cable.yml uses the async adapter in development and solid_cable in production. The async adapter only carries messages inside one process, so a broadcast triggered from bin/rails console in a terminal reaches nothing in the browser. The file says so in its own comment, and it is still the first thing to check when a local broadcast seems to vanish. Turbo Streams over Solid Cable is the same mechanism as streaming an LLM answer token by token, where the same channel carries a hundred appends instead of one prepend.

The retries you are not buying

Reaching for the noticed gem "because it handles retries" is the reason that does not survive reading the source. Noticed::DeliveryMethod inherits from Noticed.parent_class.constantize, which defaults to "Noticed::ApplicationJob", and that class is nine lines:

module Noticed
  class ApplicationJob < ActiveJob::Base
    # Automatically retry jobs that encountered a deadlock
    # retry_on ActiveRecord::Deadlocked

    # Most jobs are safe to ignore if the underlying records are no longer available
    discard_on ActiveJob::DeserializationError
  end
end

One discard_on, and the retry_on commented out. Retry policy is Active Job's, exactly as it would be in an application with no gem, and the way to get your own is to set Noticed.parent_class = "ApplicationJob" in an initializer so that delivery jobs inherit whatever retry_on and discard_on you already wrote.

What the gem genuinely buys is isolation, which is a real and different thing. One job per delivery per recipient means a Twilio outage retries the SMS without re-sending the email, and a ResponseUnsuccessful raised by the Slack method leaves the in-app row already written and visible. A hand-rolled Deliver that sent three channels in one method would either retry all three or none.

The stream name nothing checks

spec/services/notifications/deliver_spec.rb asserts the broadcast argument for argument, including the stream: have_received(:broadcast_prepend_to).with(user, :notifications, hash_including(target: "notifications")). Change the splat and that example goes red. So far so good.

The other half of the stream name is typed by hand in app/views/notifications/index.html.erb, as turbo_stream_from Current.user, :notifications, and no spec in the repository reads it. spec/requests/notifications_spec.rb signs in, marks a notification read, checks that one user cannot touch another's, and never looks at the subscription tag. Edit that view line to turbo_stream_from Current.user and the whole suite stays green while the feature is dead in every browser: the page renders, the rows are there on reload, and nothing arrives live again.

A test that would catch it has to compare the two, which means either asserting the rendered <turbo-cable-stream-source> signed name against the one the service broadcasts to, or a system test with a real browser and an Action Cable connection. Neither is free, and the version here has neither, which is worth knowing before trusting a green suite about a feature you cannot see in it.

The second index earns nothing

Two indexes sit on this table. t.references :recipient, polymorphic: true creates index_notifications_on_recipient on (recipient_type, recipient_id), and the migration then adds idx_on_recipient_type_recipient_id_read_at_50191a301d on (recipient_type, recipient_id, read_at).

Postgres can use any leading prefix of a composite index, so every query the two-column index serves is already served by the three-column one, including Current.user.notifications.recent. The cost of keeping it is one more index to update on each insert and its share of the table's disk, which on a table this size is nothing anybody will measure. Removing it is a remove_index migration and a regenerated annotation. The reason to say so rather than to quietly leave it is that t.references ... polymorphic: true indexes by default, and the default is easy to forget when adding a wider index by hand.

Take the gem the day you deliver twice

The position here is that a notifications feature with one destination should not install noticed, and that the rule is about destinations rather than about volume or taste. One table and a Deliver service is 142 lines across the model, the service, the controller and three views, it has no upgrade path to follow, and nobody has to learn a notifier DSL to add a message. The noticed gem's two-table schema, its notifier classes and its per-method config are the price of a fan-out that a one-channel product does not perform.

What would change the answer, precisely: the first requirement that one event reach two places. Not "we might add email later", which is the version of this argument that installs a dependency for a feature nobody has been asked for, but an actual ticket saying a referral conversion has to hit the bell and the inbox. At that point the hand-rolled service grows a second delivery path, then a third with its own credentials and its own failure mode, and every one of those is a worse copy of deliver_by. Take the gem then, and accept the migration: noticed_events and noticed_notifications have different columns from the single table, so moving existing rows means minting one event per row or accepting that history starts at the switch.

The second thing that would change it is a second recipient type with its own preferences, since per-recipient opt-outs are exactly what config.if was built for.

What this page does not cover

Push notifications to a device, which is where the comparison stops being close. Web push needs a service worker, a VAPID key pair and a subscription row per browser; ios and fcm, the two device delivery methods noticed ships, need a certificate or a service account behind them. This codebase has none of that: app/views/pwa/service-worker.js is the file rails new generates, with its self.addEventListener("push", ...) handler still commented out, and nothing has uncommented it. No part of the argument above transfers to that problem.

Nor does it cover notification preferences: a settings screen where somebody turns off referral notices and keeps billing ones. Neither the model here nor the gem's schema stores that, in both cases it is a table of your own and a check before delivering, and building it is the same work either way.

More on Notifications in Rails

← All Notifications in Rails articles