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.
- web-push gem + VAPID keys: add the gem, and generate and store the keys that prove the sender's identity (§2)
- push_subscriptions table: store the subscriptions the browser creates (§3)
- Notification sending job: send one notification to all of a given user's devices (§4)
- Daily reminder: send automatically at a fixed time every day (§5)
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."
end3. 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: trueendpointis UNIQUE: the destination URL for that device on the push service. A user with multiple devices (an iPhone and an Android, say) has multiple recordsp256dh_key/auth_key: the ECDH public key and the authentication secret. Extracted viagetKey()from the result of the browser'sPushManager.subscribe()user_agent: taken fromrequest.user_agentwhen 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
endThere 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
endPushSubscription.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 (whetherserviceWorker+PushManager+Notificationare all available), checks for iOS standalone mode, and fetches the current subscription state to reflect it in the UIenable():Notification.requestPermission()→pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })→POST /push_subscriptionsto the serverdisable():pushManager.getSubscription()→sub.unsubscribe()→DELETE /push_subscriptionstest(): immediately runsSendPushNotificationJob.perform_nowviaPOST /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.
| State | UI |
|---|---|
| Unsupported browser | "Your environment doesn't support notifications" |
| iOS and not standalone | A "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://onlocalhost. You need to put ngrok / Tailscale in front and test on a real device overhttps://. 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
ttlas an integer number of seconds: at first we passedttl: 24.hours(anActiveSupport::Duration) and got scolded byWebPush::Errorwith "ttl must be integer." Pass a plain number of seconds like24 * 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.
| Phase | What it covers | Key technologies |
|---|---|---|
| 1+2 | Giving the app the shape of a PWA (add to home screen / app shell caching) | manifest / Service Worker |
| 3 | Keeping question data on the client | IndexedDB / JSON API |
| 4 | Offline-only practice mode + answer queue sync | IndexedDB v2 / bulk sync |
| 5 | Web 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.
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.
Frequently Asked Questions
QWhy didn't you add a notifications_enabled flag to the User table?
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?
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?
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?
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?
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.
