MOOBON
PWA Implementation · Rails 8 / Web Push / iOS

Implementing Web Push Notifications in Rails 8Working Around the iOS 16.4+ Standalone Constraint in the UI (Phase 5)

Published May 14, 202614 min readMOOBON Tech Blog

This is the final installment of our Rails 8 PWA series. In Phase 1+2 we gave the app the shape of a PWA, and in Phase 3+4 we implemented offline studying. Now that "open the app and you can study" works, the last step is to have the app itself give users a reason to open it: Web Push notifications.

This first-hand implementation log covers the web-push gem and VAPID key management, the push_subscriptions data model, automatic cleanup of dead subscriptions, and daily delivery via Solid Queue's recurring schedule, as well as how to work around iOS 16.4+'s "notification permission can only be requested in standalone mode" constraint in the UI.

Environment
Ruby
3.4.9
Rails
8.1
Push
web-push gem / VAPID
Job
Solid Queue (recurring schedule)
Frontend
Hotwire (Stimulus) + Service Worker
Supported OS
iOS 16.4+ (standalone required) / Android
Scope
Phase 5 (Web Push notifications). Builds on Phase 1+2 (PWA / Service Worker) + Phase 3+4 (offline studying)

Introduction: Having the App Give Users a Reason to Open It

We've finally reached the last phase of turning our in-house prototype certification study app into a PWA. Phase 1+2 made it "an app on your home screen," and Phase 3+4 made it possible to "study even offline."

Up to now, everything has been about polishing the experience once you open the app. Next up: Web Push notifications. The goal tackles the opposite problem: having the app itself give users a reason to open it. In certification study, where consistency is everything, it's a mechanism to remind you, "Let's do some today, too."

The goal of Phase 5 is to deliver one study reminder per day to users who have allowed notifications. On the technical side, the whole lineup makes an appearance: the web-push gem, VAPID keys, a push_subscriptions table, Solid Queue's recurring schedule, and the Service Worker's push handler.

Another highlight is how to work around the iOS 16.4+ constraints in the UI. This article is for engineers who want to implement Web Push in Rails, and for anyone who's been stuck with "I installed the web-push gem, but notifications never arrive."

1. The Big Picture of Web Push and the Implementation Tasks

1-1. How Web Push Works and the iOS 16.4+ Constraints

Let's first lay out the components of Web Push.

  • Browser: creates a "subscription" with the user's permission. This is a set consisting of "the destination (endpoint) for notifications to this device and the encryption keys"
  • Push service: a relay run by the browser vendor (for Chrome, Google's FCM, etc.). Notifications reach the device through it
  • Backend (Rails): asks the push service to "send a notification to this destination" (the VAPID keys that prove its identity are explained below)
  • Service Worker: receives the notification on the device and displays it

VAPID (Voluntary Application Server Identification) is a public/private key mechanism the backend uses to tell the push service, "I'm the legitimate sender." The public key is handed to the browser (it can't subscribe without it), and the private key is kept secret on the server.

Web Push on iOS comes with a peculiar condition. iOS Safari added Web Push support in 2023, but notification permission can only be requested from "a PWA that's been added to the home screen and launched in standalone mode". Calling Notification.requestPermission() in Safari doesn't even show the permission dialog, so we detect whether the app is open as a PWA or in a Safari tab and show different UI accordingly (implemented in §6-3).

1-2. Implementation Task List

Here's what Phase 5 builds, split into server side and client side, along with the section that covers each task.

Server side
  1. web-push gem + VAPID keys: add the gem, and generate and store the keys that prove the sender's identity (§2)
  2. push_subscriptions table: store the subscriptions the browser creates (§3)
  3. Notification sending job: send one notification to all of a given user's devices (§4)
  4. Daily reminder: send automatically at a fixed time every day (§5)
Client side
  1. Service Worker push handler: display incoming notifications on screen (§6-1)
  2. Subscription management UI: turning notifications on / off, plus guidance that works around the iOS constraint (§6-2 / §6-3)

2. Adding the web-push Gem and Generating / Storing VAPID Keys

First, add gem "web-push" to the Gemfile. VAPID keys can be generated with WebPush.generate_key.

We chose Rails credentials (config/credentials.yml.enc) as the place to store the keys. The VAPID private key is sensitive ("if it leaks, someone can impersonate your server and send notifications"), so credentials, which are committed to the repository encrypted, are a perfect fit. Normally you'd edit them interactively with bin/rails credentials:edit, but since we were building this with Claude Code (an AI coding agent), we wrote a script that writes them non-interactively via the Rails runner instead of an interactive flow that launches an editor.

# run with bin/rails runner
keys = WebPush.generate_key
current_yaml = Rails.application.credentials.read
current_data = YAML.safe_load(current_yaml, permitted_classes: [Symbol]) || {}
current_data["web_push"] = {
  "vapid_public_key"  => keys.public_key,
  "vapid_private_key" => keys.private_key,
  "vapid_subject"     => "mailto:you@example.com"
}
Rails.application.credentials.write(current_data.to_yaml)

Note that vapid_subject must be a URL starting with mailto: or https:// (RFC 8292). It's passed to the push service as the sender's contact information.

Also, rather than reading credentials directly from jobs and controllers, we expand them into config.web_push in an initializer (config/initializers/) that runs once at boot. That makes it easier to swap values in tests or to log a warning if the keys aren't configured in production.

# config/initializers/web_push.rb
Rails.application.config.web_push = ActiveSupport::OrderedOptions.new

creds = Rails.application.credentials.web_push || {}
creds = creds.symbolize_keys if creds.respond_to?(:symbolize_keys)

Rails.application.config.web_push[:vapid_public_key]  = creds[:vapid_public_key]
Rails.application.config.web_push[:vapid_private_key] = creds[:vapid_private_key]
Rails.application.config.web_push[:vapid_subject]     =
  creds[:vapid_subject] || "mailto:hello@example.com"

if Rails.env.production? &&
   Rails.application.config.web_push[:vapid_public_key].blank?
  Rails.logger.warn "[web_push] VAPID keys are not configured."
end

3. The Subscription Data Model (push_subscriptions)

Next, we create a table to store the "subscriptions" the browser creates on the server side.

create_table :push_subscriptions do |t|
  t.references :user, null: false, foreign_key: true
  t.string :endpoint,   null: false
  t.string :p256dh_key, null: false
  t.string :auth_key,   null: false
  t.string :user_agent
  t.timestamps
end
add_index :push_subscriptions, :endpoint, unique: true
  • endpoint is UNIQUE: the destination URL for that device on the push service. A user with multiple devices (an iPhone and an Android, say) has multiple records
  • p256dh_key / auth_key: the ECDH public key and the authentication secret. Extracted via getKey() from the result of the browser's PushManager.subscribe()
  • user_agent: taken from request.user_agent when the subscription is registered, so we can later tell which device a subscription belongs to

The model has only belongs_to :user and presence / uniqueness validations. On the User side, add has_many :push_subscriptions, dependent: :destroy. We don't keep a flag for turning notifications on / off. Whether a subscription record exists is itself the on/off state for notifications (see the FAQ).

4. Sending Notifications: Automatic Cleanup of Dead Subscriptions

The job that sends one notification to a specific user is SendPushNotificationJob. It loops over all of the user's subscriptions (= all their devices) and sends to each.

class SendPushNotificationJob < ApplicationJob
  queue_as :default

  def perform(user_id, title:, body:, path: "/")
    user = User.find_by(id: user_id)
    return unless user
    config = Rails.application.config.web_push
    return if config[:vapid_public_key].blank?

    user.push_subscriptions.find_each do |sub|
      payload = JSON.generate(
        title: title,
        options: {
          body: body, icon: "/icon-192.png", badge: "/icon-192.png",
          data: { path: path }
        }
      )

      begin
        WebPush.payload_send(
          message: payload,
          endpoint: sub.endpoint,
          p256dh: sub.p256dh_key,
          auth: sub.auth_key,
          vapid: {
            public_key: config[:vapid_public_key],
            private_key: config[:vapid_private_key],
            subject: config[:vapid_subject]
          },
          ttl: 24 * 60 * 60
        )
      rescue WebPush::ExpiredSubscription,
             WebPush::InvalidSubscription,
             WebPush::Unauthorized
        sub.destroy  # permanently undeliverable → discard on the spot
      rescue WebPush::Error => e
        Rails.logger.warn "[push] failed sub_id=#{sub.id}: #{e.class}"
      end
    end
  end
end

There are two design points here.

The first is automatic cleanup of dead subscriptions. ExpiredSubscription / InvalidSubscription / Unauthorized are cases where the push service is explicitly saying "this endpoint is no longer valid" (the user turned off notifications, switched devices, etc.). Since it's certain these will never be delivered, we call sub.destroy on the spot, and no separate sweep job is needed later. On the other hand, any other WebPush::Error (such as a temporary network error) isn't deleted, only logged. The key is to separate "permanent" from "temporary" by error type.

The second is ttl: 24 * 60 * 60 (24 hours). A study reminder loses its value after 24 hours. A longer TTL wastes push service resources, and users are annoyed by stale notifications arriving late. Note that TTL is in seconds, so passing an ActiveSupport::Duration like 24.hours causes an error (more on this in the gotchas section).

5. Daily Reminders: Solid Queue's Recurring Schedule

With the per-user sending job in place, we create DailyStudyReminderJob to trigger it every day.

class DailyStudyReminderJob < ApplicationJob
  queue_as :default

  def perform
    user_ids = PushSubscription.distinct.pluck(:user_id)
    User.where(id: user_ids).find_each do |user|
      I18n.with_locale(user.locale.presence || I18n.default_locale) do
        SendPushNotificationJob.perform_later(
          user.id,
          title: I18n.t("pwa.notification.daily_reminder_title"),
          body:  I18n.t("pwa.notification.daily_reminder_body"),
          path:  "/exams/sap/sessions/new"
        )
      end
    end
  end
end

PushSubscription.distinct.pluck(:user_id) extracts only "users who have a subscription," without duplicates. Since there's no flag on the User table, this is what defines "users to notify." The copy is localized per user with I18n.with_locale, and individual sends are parallelized with perform_later (Solid Queue runs them in parallel).

The schedule goes in config/recurring.yml. In this project, we don't support per-user preferred times; it's fixed at 20:00 JST every day (an MVP trade-off).

# config/recurring.yml
production:
  daily_study_reminder:
    class: DailyStudyReminderJob
    queue: default
    schedule: every day at 11:00 # 20:00 JST (UTC+9)

The container's time zone is UTC, so every day at 11:00 corresponds to 20:00 JST. Solid Queue's recurring tasks are based on fugit, and this is equivalent to the cron expression 0 11 * * *. We didn't enable recurring tasks in the dev environment; instead we tested by calling DailyStudyReminderJob.perform_now via the Rails runner.

One caveat: Solid Queue's recurring tasks don't run schedules that fall within periods when the worker process is down (there's no catch-up execution for missed runs). A study reminder can tolerate an occasional miss, so we haven't added monitoring for now, but for jobs that "absolutely must not be missed," you'd need separate monitoring of execution records.

6. Client Side: The Service Worker and the Subscription Management UI

With the server side in place, we implement the browser side: the Service Worker that receives notifications and the UI that manages subscriptions.

6-1. push / notificationclick Handlers

We add push and notificationclick handlers to the service-worker.js built in Phase 1+2 (the fallback title is the app's Japanese name, "Certification Study App").

self.addEventListener("push", (event) => {
  if (!event.data) return
  let payload
  try {
    payload = event.data.json()
  } catch (_e) {
    payload = { title: "資格学習アプリ", options: { body: event.data.text() } }
  }
  const title = payload.title || "資格学習アプリ"
  const options = payload.options || {}
  event.waitUntil(self.registration.showNotification(title, options))
})

self.addEventListener("notificationclick", (event) => {
  event.notification.close()
  const path = event.notification.data?.path || "/"
  event.waitUntil(
    clients.matchAll({ type: "window", includeUncontrolled: true })
      .then((clientList) => {
        for (const client of clientList) {
          if (new URL(client.url).pathname === path && "focus" in client) {
            return client.focus()
          }
        }
        if (clients.openWindow) return clients.openWindow(path)
      })
  )
})

The push handler takes the payload that SendPushNotificationJob sent with JSON.generate({ title, options }), extracts it with event.data.json(), and passes it to showNotification(). notificationclick focuses an existing tab if one is already open, and opens a new one otherwise. includeUncontrolled: true includes tabs not controlled by that Service Worker as candidates. Since we changed the Service Worker logic, we bump VERSION from v1 → v2 so the old caches are discarded. Note that this v2 is the version as of Phase 5. The final version once the series was complete is v3, which includes the Turbo fix found during verification in Part 1 (v1 = Phase 2 / v2 = Phase 5 / v3 = verification for the Phase 1+2 article).

6-2. A Stimulus Subscription Controller

We create push_subscription_controller.js to handle enabling and disabling subscriptions. The main methods are:

  • connect(): checks for support (whether serviceWorker + PushManager + Notification are all available), checks for iOS standalone mode, and fetches the current subscription state to reflect it in the UI
  • enable(): Notification.requestPermission() → pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) → POST /push_subscriptions to the server
  • disable(): pushManager.getSubscription() → sub.unsubscribe() → DELETE /push_subscriptions
  • test(): immediately runs SendPushNotificationJob.perform_now via POST /push_subscriptions/test (for verification)

You need exactly one conversion utility. The VAPID public key arrives as a URL-safe base64 string, but applicationServerKey requires a Uint8Array. A roughly 10-line utility called urlBase64ToUint8Array() does the conversion. It's safe to hand the VAPID public key to the client (in fact, it can't subscribe without it). The private key never leaves the server.

6-3. Working Around the iOS Standalone Constraint in the UI

As mentioned in §1, on iOS you can't request notification permission unless the app has been "added to the home screen and launched in standalone mode." We handle this in the UI. The notifications section lives on the profile page, and connect() determines the state and switches what's displayed.

StateUI
Unsupported browser"Your environment doesn't support notifications"
iOS and not standaloneA "Please add the app to your home screen first" hint (no enable button)
Supported, not subscribed"Enable notifications" button
Supported, subscribed"Turn off notifications" + "Send test" buttons

The key is not showing the enable button on iOS when the app isn't in standalone mode. If you show the button, tapping it does nothing (no dialog appears), and users conclude it's broken. Telling them "Please add the app to your home screen first" instead lets them follow the right steps. If you see Apple's constraint not as an "annoying spec" but as "a safeguard that keeps users from being asked for notification permission unexpectedly," this UI becomes a natural way to guide them.

7. Verification: Isolating the Issue All the Way Down to the Server Logs

We verified the whole path from subscribing to receiving a notification on a real iPhone.

  • Launch from a PWA added to the home screen from Safari (standalone required; adding it from Chrome won't work)
  • Profile → "Study reminder notifications" → "Enable" → tap "Allow" in the iOS permission dialog
  • "Test notification" button → an iOS notification arrives

At one point we thought "the test notification isn't arriving," so we checked the production CloudWatch logs (/ecs/app-rails): every SendPushNotificationJob had succeeded with zero errors. WebPush.payload_send completing means the message was handed off to Apple's push server successfully. That let us narrow it down: the server was fine; the problem was on the display side.

The real cause: the iPhone was in a Focus mode. The banner was suppressed, but the notification itself had arrived and was sitting in Notification Center. The punchline was "we thought it was a bug, but it was an OS setting." If notifications don't seem to be arriving, first confirm a successful send in the server logs → then suspect the device's Focus mode / notification settings; that order works well.

8. Gotchas and Decision Log

  • The iOS standalone constraint is hard to test: in the dev environment, you can't even add the app to the home screen over http:// on localhost. You need to put ngrok / Tailscale in front and test on a real device over https://. It's the same setup as in Phase 1+2, but Web Push adds one more layer of hassle since you have to verify all the way through the permission dialog
  • Pass Web Push's ttl as an integer number of seconds: at first we passed ttl: 24.hours (an ActiveSupport::Duration) and got scolded by WebPush::Error with "ttl must be integer." Pass a plain number of seconds like 24 * 60 * 60

9. Looking Back on the Five-Phase PWA Conversion

From Phase 1 through Phase 5, we've now completed the full PWA conversion of this certification study app. Let's look back on the whole thing.

PhaseWhat it coversKey technologies
1+2Giving the app the shape of a PWA (add to home screen / app shell caching)manifest / Service Worker
3Keeping question data on the clientIndexedDB / JSON API
4Offline-only practice mode + answer queue syncIndexedDB v2 / bulk sync
5Web Push notifications (daily reminders)web-push / VAPID / Solid Queue

What paid off across all five phases was that Rails 8 comes with a PWA scaffold out of the box. It made a big difference that Phase 1 started not from "designing from scratch" but from "filling in a framework that was already there." From Phase 2 on, the work was basically "fitting standard PWA building blocks into a Rails monolith," and we never needed to bring in any special architecture.

The iOS constraints (Web Push requires standalone mode, Background Sync isn't supported) remained to the end, but we accept them as "the cost of not going native." For constraints with no workaround, you either handle them in the UI or provide an alternative path, and we've done both in this series. What's left is tuning, once the user base grows, while measuring push delivery rates and sync failure rates.

Endnote

Afterword

As the final installment of the Rails 8 PWA series, this article walked through implementing Web Push notifications. Technically, it comes together straightforwardly with the web-push gem + VAPID + Solid Queue, but the real value of the implementation lies in not writing off the iOS 16.4+ standalone constraint as an "annoying spec," and instead covering it with a UI that guides users to the right steps. How to live with constraints you can't avoid is a theme not just for Web Push, but for PWA development as a whole.

Across all five phases, we gradually fitted the standard PWA building blocks (manifest / Service Worker / IndexedDB / Web Push) into a Rails 8 monolith. We hope it serves as a useful reference for anyone facing the same decision, as one real-world example of "how far you can go toward an app-like experience without writing a native app."

At MOOBON, we take on new Rails / web application development, feature additions to existing projects, and PWA conversion design. We also welcome requests such as "we want to add Web Push but we're stuck on iOS support" or "we'd like to map out an overall roadmap for converting to a PWA." Feel free to reach out at info@moobon.jp.

FAQ

Frequently Asked Questions

QWhy didn't you add a notifications_enabled flag to the User table?
A

Whether notifications are on or off can be determined by whether the user has any records in the push_subscriptions table. If you also keep a boolean like notifications_enabled on User, you open the door to inconsistent states: "the flag is true but there's no subscription record," or "the flag is false but a record is still there." The existence of a subscription is itself the state, so the flag is redundant. When you need to find "users to notify," User.where(id: PushSubscription.distinct.pluck(:user_id)) does the job. It's a decision in favor of simplicity: don't store the same state twice.

QIs there a way to show the notification permission dialog on iOS without adding the app to the home screen?
A

No. This is Apple's specification. iOS Safari added Web Push support in 16.4 (2023), but notification permission can only be requested "from a PWA that has been added to the home screen and launched in standalone mode." Calling Notification.requestPermission() in a regular Safari tab doesn't even show the permission dialog. There's no workaround, so the only option is to handle it in the UI. In our implementation, we detect isIOS() && !isStandalone() and show a hint in the notifications section saying "Please add the app to your home screen first," guiding users to the right steps.

QShould VAPID keys go in environment variables or Rails credentials?
A

In this project, we put them in Rails credentials (config/credentials.yml.enc). The VAPID private key is sensitive: if it leaks, someone can impersonate your server and send notifications. Environment variables are convenient, but they risk leaking into process listings, logs, and error-tracking services. credentials.yml.enc is committed to the repository encrypted, and only the decryption key (master.key) is managed separately, which makes it a straightforward way to handle secrets. That said, some organizations standardize on environment variables in the 12-factor style, and that comes down to organizational policy. What matters is "never put the private key in plaintext YAML or code"; as long as you meet that condition, either is fine.

QIs it reasonable to automatically delete failed push_subscriptions?
A

We think so. When WebPush.payload_send returns ExpiredSubscription / InvalidSubscription / Unauthorized, the push service (FCM, etc.) is explicitly saying "this endpoint is no longer valid." It happens when the user turns off notifications, clears browser data, switches devices, and so on. These aren't temporary errors where "the next send might get through"; they're states where it's certain the message will never be delivered, so it's fine to call sub.destroy on the spot. Conversely, temporary network-related errors (other subclasses of WebPush::Error) aren't deleted, only logged, so they're retried on the next delivery. The key is to separate "permanent" from "temporary" by the type of error.

QHow reliable is Solid Queue's recurring schedule?
A

Solid Queue's recurring schedule is database-driven: you write cron-like schedules in config/recurring.yml. One caveat is that schedules falling within periods when the worker process is down are not executed (unlike some cron setups, it doesn't catch up on missed runs at startup). It's a good fit for jobs like daily reminders, which lose their value if they can't be sent at that time, but jobs that must never be missed need separate monitoring of execution records. Since this project's job is a study reminder, we consider an occasional miss acceptable and haven't added monitoring for now. If its importance increases, we plan to add alerts on the job execution logs.

MOOBONISO/IEC 27001 CertificationIT Introduction Subsidy Support ProviderAWS Partner Select Tier Services
Copyright © 2026 MOOBON, Inc. All Rights Reserved.
Standard: ISO/IEC 27001:2022
Scope: Web system design support / Development, operation, and maintenance of in-house cloud services / Contract system development, operation, and maintenance / Server construction, operation, and maintenance