The last time I shipped push notifications, I did it from my bed at 3 AM. The feature used the Firebase Cloud Messaging SDK, and I had no idea how to exercise it locally, so the plan of my little team was: hey Fred, merge your changes, deploy to production in the middle of the night (in case something breaks), pull out your phone, and see if it buzzes. Repeat until it buzzes. Eventually, it buzzed.

A few years later, on a different app, I had to do it again. This time, at scale.

The shape of the work

The product was a Rails platform for law education. The task: migrate roughly 25 legacy mailer notifications to email + PWA web push, behind a feature flag, with a team that would maintain this after me, and a QA group who would not accept "it works on my machine."

The legacy pattern, repeated dozens of times across the codebase:

SomeMailer.delay.some_method(user, record)

Email only. Delayed Job. We needed each of these to become email plus web push, flag-gated so we could roll out per-cohort.

The gem of choice was noticed by Chris Oliver (from the beloved GoRails). Its v2 rewrote the API around Notifier classes with pluggable delivery methods. One notifier per event, each with deliver_by :email and deliver_by :web_push. Clean fit, on paper.

The same correction, four notifiers in

QA found a regression the next morning. Clicking some notifications would make the user land on a non-existent URL. We fixed it. Did the next one. And the next.

Around notifier number four, I noticed I was making the same corrections over and over...

That's the signal to stop correcting the same thing and start writing it down.

The skill

By notifier five, the pattern was stable enough to write down. I dropped it into .claude/skills/noticed-migration.md: an entry-point gate, a notifier template, a spec pattern.

# At the entry point, not inside the notifier
if FeatureFlag.noticed_notifications
  Notifications::SomeEventNotifier.deliver(user, record:)
else
  SomeMailer.delay.some_method(user, record)
end
module Notifications
  class SomeEventNotifier < ApplicationNotifier
    required_params :user, :record

    deliver_by :email do |config|
      config.mailer = "SomeMailer"
      config.method = :some_method
      config.args   = -> { [params[:record].id] }
      config.if     = -> { recipient.notification_preference.email_notifications_enabled? }
      config.enqueue = true
    end

    deliver_by :web_push, class: "DeliveryMethods::WebPush"

    def push_title = "..."
    def push_body  = "..."
    def push_url   = Rails.application.routes.url_helpers.some_path(params[:record])
    def push_options = { tag: "some_event" }
  end
end

The second migration took about half the time. The one after that, a third. Every correction I'd made twice became a Do or Don't in the file, and the next migration never needed it again:

  • Don't put FeatureFlag.noticed_notifications inside config.if. Two older notifiers did, and copy-paste was spreading it. The flag is a routing concern, not a delivery concern.
  • Don't add a server-side if to deliver_by :web_push. The delivery class already short-circuits when the recipient has no push subscription. A second check is redundant and hides the real gate.
  • Do make mailer signature changes backwards-compatible (record_id = nil with a params[:record] fallback) until the old Delayed Job queue has drained. Learned that one from an overnight wave of ArgumentErrors.
  • Do find every call site for the target mailer method. Controllers, model callbacks, interactors, jobs. Grep wide.
  • Do verify push_url against config/routes.rb. Never trust the URL in the ticket (AND DON'T HALLUCINATE IT!).

By the tenth notifier, the work was mechanical. By number twenty, it was almost completely automatic.

Only then did I lean on the AI workflow. A full migration could be done 95% of the way by giving Claude three prompts, each of which calls other skills: /tdd-skill to drive red/green/refactor, /commit to write the message, and /code-review, a self-made skill I'd written earlier that codifies the review checks I kept missing on my own PRs.

/task-implement <jira-issue-url> using the /noticed-migration skill
/code-review last commit
/simplify last commit

Some bumps on the road

A bug came in: "notification does not appear." On my machine it worked. On my teammate's machine it worked. On the tester's machine it silently vanished. After a day of back-and-forth, we figured out their browser was running a stale service worker from a previous deploy, and the old worker had no idea how to handle the new payload.

A few tweaks made service worker testing boring again:

  • "Update on reload" under DevTools > Application > Service Workers. We added this to the checklist for anyone exercising push flows.
  • skipWaiting() + clients.claim() in the install hook, so new versions took over without a full close-and-reopen.
  • A habit of unregistering the service worker and clearing sessionStorage between test rounds. It sounds fussy; it saved hours.

Close cousin: "I clicked send test and nothing happened." After some archaeology: the browser does not display a push notification if the tab that would receive it is focused. It assumes you're already looking at the app. The workflow we ended up using was: click "Send test", then immediately Cmd+Tab away. Then the notification shows. Nobody likes this kind of workaround, but at least it was written down (if you know a better way, I'm all ears).

The real bug: subscriptions leaking across users

The meaty bug came at the end.

A coworker kept getting my notifications after I signed out and he signed in on the same laptop.

The push_subscriptions table had four columns that mattered: user_id, endpoint, p256dh_key, and auth_key, plus a global uniqueness index on endpoint. The endpoint is "this browser on this device": the push vendor mints one, we store it, we push to it later. The two keys are the crypto material the browser needs to decrypt the payload. The row ties that device to a single user_id, which is where the trouble started.

The bug report, roughly: "I signed out. My coworker signed in on the same laptop. He started getting my notifications. Also, his own notifications never arrive."

Three problems stacked together:

  1. Sign-out didn't destroy the PushSubscription row. User A logged out; the row stayed, still pointing at A. The server kept pushing to it.
  2. The global uniqueness constraint made recovery impossible. When user B signed in and the client tried to register its endpoint, the insert failed (that endpoint already belonged to A). The client swallowed the 422 and moved on. B had no subscription.
  3. Putting (1) and (2) together: the device silently became a broadcaster for whoever first claimed it, not for whoever was currently logged in.

The fix lives on both sides of the wire.

Server side. Look up the endpoint globally, not scoped to current_user, and force-reassign:

# app/controllers/push_subscriptions_controller.rb
subscription = PushSubscription.find_or_initialize_by(
  endpoint: params[:push_subscription][:endpoint]
)
subscription.assign_attributes(push_subscription_params.merge(user: current_user))
subscription.save!

And destroy the row inside the sign-out request itself, while the session is still valid:

# app/controllers/users/sessions_controller.rb#destroy
if params[:push_endpoint].present?
  current_user.push_subscriptions
              .where(endpoint: params[:push_endpoint])
              .destroy_all
end
super

A separate DELETE /push_subscription sent alongside sign-out would sometimes arrive after the session was gone and 401 out. Threading the endpoint through Devise's own request dodges the race.

Client side. Two pieces. On every boot, if a subscription already exists in the browser, re-POST it so the server can reassign ownership to current_user. And on logout click, synchronously tack ?push_endpoint=... onto the sign-out link:

function handleLogoutClick(event) {
  const link = event.target.closest?.('a[data-push-cleanup]');
  if (!link) return;

  const endpoint = sessionStorage.getItem("pushEndpoint");
  if (!endpoint) return;

  const url = new URL(link.href, window.location.origin);
  url.searchParams.set("push_endpoint", endpoint);
  link.href = url.toString();
}

The bug inside the bug

While testing the fix, we hit a race.

boot() runs on every Turbo page load. The sync function is async; its first await is on navigator.serviceWorker.getRegistration(). When two Turbo navigations fire in quick succession (a click that triggers a redirect, a nested frame flashing in), two copies of the sync function run concurrently. Both pass the "have we synced yet?" check. Both POST the same endpoint. The server reassigns, recreates, reassigns again, and some users ended up with two or three PushSubscription rows for the same browser, which multiplied every outgoing notification.

The dedup key only got set after the POST succeeded, which was several awaits in. That gap is where the race lived. The fix: a module-level in-flight flag, set synchronously before the first await, so any concurrent boot() sees it and bails.

let pushSubscriptionSyncInFlight = false;

async function syncPushSubscriptionToCurrentUser() {
  if (pushSubscriptionSyncInFlight) return;
  if (sessionStorage.getItem("pushSubscriptionSyncedForUser") === currentUserId()) return;

  pushSubscriptionSyncInFlight = true;
  try {
    // ... the rest of the sync, including awaits ...
  } finally {
    pushSubscriptionSyncInFlight = false;
  }
}

The synchronous set before the first await is load-bearing. Move it below any await and the race comes right back.

What the tests actually assert

Three scenarios, each of them a former bug:

  1. Sign-out cleans up this device's subscription. After DELETE /users/sign_out?push_endpoint=..., the row is gone. The next send_test to that user does not reach the device.
  2. Sign-in reassigns a lingering row from a previous user. Seed a subscription owned by A with endpoint E. Sign in as B in the same browser. After the next boot, E belongs to B.
  3. send_test fans out correctly. One user signed in on two browsers gets two notifications. Not one. Not three.

What I learned

The skill compounds. Ten migrations in, that file was more authoritative than my memory. Most of what I learned on this project isn't in the PRs; it's in a 200-line markdown file the next person (or agent) to touch notifications will read before they type anything.


I'm Fred Sapuppo: a Rails developer who writes down every correction twice so I don't make it three times. I no longer deploy at 3 AM.

More from OffTheRails