Rails web push notifications, from VAPID to the 410 that deletes the row
September 22, 2026
A notification that only exists while the tab is open is a badge, not a notification. Web push is the
part that reaches somebody who closed the tab three hours ago, and it is the part Rails leaves to
you.
Read this as the layer above, not instead of.
The Turbo Stream broadcast
is what the notifications module in this codebase
actually implements, on top of
a notification model with no gem behind it:
a record, a polymorphic recipient, and a push to the tabs that happen to be open. There is no
web-push gem in that Gemfile, no PushSubscription model, no VAPID key and no service worker with a
line of live code in it. Everything below is a plain Rails tutorial, and none of it ships in the
product.
Two files and no route, which is what Rails 8 gives you
Running rails new on Rails 8.1.3 creates exactly two things aimed at push:
app/views/pwa/manifest.json.erb and app/views/pwa/service-worker.js. Both are rendered by
Rails::PwaController, fourteen lines in railties that call skip_forgery_protection and render
each template with layout: false.
What the generator does not do is turn any of it on. The generated config/routes.rb carries this,
commented:
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
The generated app/views/layouts/application.html.erb carries the matching link tag, also
commented. And service-worker.js as shipped is twenty-five lines of comment: a push listener
that calls self.registration.showNotification(title, options), and a notificationclick listener
that walks clients.matchAll looking for a window already on the notification's path before falling
back to clients.openWindow.
So Rails has decided where the code goes and written the boring half of it, which is genuinely
useful, and has implemented nothing. Uncommenting those three lines gets you a service worker that
registers and a manifest that serves. It does not get you a key pair, a subscriptions table, or a
single byte sent to a push service.
The protocol is three RFCs, and the payload budget is 4096 bytes
Web push is not one specification. RFC 8030, "Generic Event Delivery Using HTTP Push", defines the
transport: your server POSTs to an endpoint URL that the browser vendor owns, and the push service
delivers to the browser. RFC 8291, "Message Encryption for Web Push", defines how the body is
encrypted so the push service relays something it cannot read, using ECDH on the P-256 curve and
AES-128-GCM. RFC 8292, "Voluntary Application Server Identification (VAPID) for Web Push", defines
how your server proves it is the same server the user subscribed to.
The number worth writing down comes from section 7.2 of RFC 8030: "Push services MUST NOT return a
413 status code in responses to an entity body that is 4096 bytes or less in size." Above that you
are at the mercy of the push service, and the encryption overhead comes out of the same budget.
Which settles a design question before you meet it. Do not push the notification's content. Push a
record id and a path, let the service worker draw a title and a body short enough to fit a lock
screen, and let the click go and fetch the real thing. A push message is a doorbell.
VAPID keys, and what rotating them costs
Generating the pair is one call:
vapid_key=WebPush.generate_keyvapid_key.public_key# goes to the browservapid_key.private_key# goes to Rails credentials
The public key is handed to the browser as applicationServerKey. MDN describes it as "a
Base64-encoded string or ArrayBuffer containing an ECDSA P-256 public key that the push server will
use to authenticate your application server", and adds the correction people need twice: "This key
IS NOT the same ECDH key that you use to encrypt the data." The ECDH keys are the p256dh and
auth values the browser gives you per subscription.
VAPID also wants a contact. RFC 8292 says the sub claim "SHOULD include a contact URI for the
application server as either a 'mailto:' (email) or an 'https:' URI", which is how a push service
reaches a human when your server starts behaving badly. A real address, not mailto:noreply@.
The cost of the choice, and it is the one nobody prices: the key pair is permanent. A subscription
is created against one applicationServerKey, and MDN is explicit that from then on "all messages
from your application server must use the VAPID authentication scheme, and include a JWT signed with
the corresponding private key". Sign with a different key and the push service refuses, which the
gem surfaces as WebPush::Unauthorized. So losing the private key does not mean regenerating it, it
means every subscription you have ever stored is now undeliverable and every user has to opt in
again. Put it in credentials, back up config/master.key, and treat it like the Rails secret key
base.
The service worker only controls what sits under its own path
Service worker scope is the trap that costs an afternoon, because the registration succeeds and the
push simply never arrives. MDN states the rule plainly: the default scope is "the directory where
the service worker script is located", and "a service worker can't have a scope broader than its own
location, unless the server specifies a broader maximum scope in a Service-Worker-Allowed header
on the service worker script".
That is the whole reason Rails routes the file at /service-worker rather than letting it through
Propshaft. Serve the same JavaScript from /assets/service-worker-a1b2c3.js and its scope is
/assets/, so it controls nothing a user will ever visit, and the digest in the filename changes
the registration's identity on every deploy for good measure.
Uncomment the route, register it from the page, and keep the path at the root:
The push handler in the Rails template is already the right shape. The one thing to add is that the
options object it passes through to showNotification is where data: { path: "/notifications" }
has to live, because the notificationclick listener underneath it reads
event.notification.data.path and does nothing useful if that key is missing.
PushSubscription is a table you write yourself
Three columns carry a subscription, and all three come from the browser:
The unique index goes on endpoint, not on user_id, and that is the modelling decision the whole
feature rests on. A subscription identifies a browser profile on a device, so one person with a
laptop, a phone and Firefox at home is three rows. Sending means iterating user.push_subscriptions
and accepting that some of them are dead.
Upsert on the endpoint rather than creating. A returning visitor's page calls
registration.pushManager.getSubscription(), gets the subscription that already exists, and posts
it to you again; a naive create! either duplicates the row or raises on the index you just added,
on a perfectly ordinary second visit.
The gem is web-push, currently 3.1.0, released 2025-12-18. Maintenance is worth checking before
you depend on it: the name most tutorials still use is webpush, zaru's original, and the gem above
is Pushpad's fork of it, which describes itself as carrying "many improvements, bug fixes and
frequent updates". Read the repository before you copy a Gemfile line from a 2019 blog post.
ttl defaults to 2419200 seconds, which is twenty-eight days of a push service holding a message
for a device that never comes back online. For anything time-sensitive that default is wrong, and
600 is a more honest number for a notification that says something just happened.
Every call is a synchronous HTTPS request to a third party, one per subscription, so this runs in a
job and never in a request. Three separate timeout options exist for exactly that reason:
ssl_timeout, open_timeout and read_timeout.
The 410 that means delete the row
Push services expire subscriptions on their own schedule: a browser reinstalled, a profile cleared,
a service worker unregistered. The way you find out is the response code, and the gem maps codes to
classes in lib/web_push/request.rb:
Status
Exception
410 Gone
WebPush::ExpiredSubscription
404 Not Found
WebPush::InvalidSubscription
401, 403
WebPush::Unauthorized
413
WebPush::PayloadTooLarge
429
WebPush::TooManyRequests
Both of the first two mean the same thing operationally, and the handler is three lines:
Skip that rescue and nothing breaks loudly. The job raises, Solid Queue retries it, the retry raises
the same way, and a year later the table is mostly corpses with a bounded amount of real work buried
in them.
Here is the test that stays green while this is broken. A request spec that stubs
WebPush.payload_send and asserts the job was enqueued passes whether or not the rescue exists,
because the stub never returns a 410. The only spec that catches it is one that makes the stub raise
WebPush::ExpiredSubscription and then asserts the row is gone. Write that one.
Asking for permission is a thing you get to do once
Notification.requestPermission() resolves to "granted", "denied" or "default". What makes it
unlike every other browser prompt is that "denied" is terminal: the decision is remembered for the
origin, later calls resolve to "denied" without drawing anything, and nothing in your JavaScript
can reopen it. The user has to go into browser site settings, which nobody does.
Two consequences follow. The call has to happen inside a real user gesture, MDN's example puts it in
a click handler, and browsers increasingly refuse it otherwise. And asking on page load, before the
visitor knows what your product notifies about, spends the only ask you have on somebody with no
reason to say yes.
The position, and what would change it: ask from a settings screen, behind a control the user
deliberately switched on, never from a banner on first paint. The cost is that a screen has to be
built and most people will never find it, so your opt-in rate will look bad next to the
ask-everyone approach. The ask-everyone approach converts better for a week and then owns a
permanently blocked origin, and there is no migration back from that.
One more flag belongs in the subscribe call. MDN: userVisibleOnly "is required in some browsers
like Chrome and Edge. They will reject the Promise if userVisibleOnly is not set to true." Set
it, and accept what it means: every push must produce a visible notification. Silent background
pushes are not available to you.
iOS wants the web app on the Home Screen first
Safari on iOS is the constraint that decides whether this feature is worth building at all, and it
is not a bug you can code around. Apple shipped web push in iOS and iPadOS 16.4, and the WebKit
announcement states the condition: "A web app that has been added to the Home Screen can request
permission to receive push notifications as long as that request is in response to direct user
interaction."
A page open in a Safari tab, therefore, gets nothing. Not a prompt, not a subscription. The user has
to hit the share sheet, pick Add to Home Screen, open the resulting icon, and only then can your
button ask. Which means app/views/pwa/manifest.json.erb, the file that looked like a formality,
becomes load-bearing: the manifest has to be served, linked from the layout, and declare display
as standalone or fullscreen for the thing to install as a web app at all. Both of those lines
are commented out in a fresh Rails app.
Put a number on the funnel before you commit. On iOS, a push subscription costs you an install step
that most visitors will not complete, on top of a permission prompt they can still refuse. If your
audience is mobile and mostly iPhone, in-app notifications plus email will reach more people than
web push will, for a fraction of the work.
What this page leaves out
Native mobile push is a different stack entirely. APNs and FCM through their own SDKs have no
overlap with anything above except the word "notification", and nothing here helps you ship an
iOS or Android app.
Delivery receipts do not exist. The push service accepts your POST with a 201 and tells you nothing
about whether a device was awake, whether the notification was drawn, or whether anybody saw it.
Any read tracking has to come back through a fetch you put in the service worker yourself.
And the notifications module in this codebase implements none of it. The PushSubscription table,
the VAPID credentials, the job and the rescue are all work you would be adding.
The in-app side
is the part that is already built and tested, and it is the prerequisite either way: web push tells
somebody something happened, and the list they open afterwards still has to exist.
A notification that only exists while the tab is open is a badge, not a notification. Web push is the part that reaches somebody who closed the tab three hours ago, and it is the part Rails leaves to you.
Read this as the layer above, not instead of. The Turbo Stream broadcast is what the notifications module in this codebase actually implements, on top of a notification model with no gem behind it: a record, a polymorphic recipient, and a push to the tabs that happen to be open. There is no web-push gem in that Gemfile, no
PushSubscriptionmodel, no VAPID key and no service worker with a line of live code in it. Everything below is a plain Rails tutorial, and none of it ships in the product.Two files and no route, which is what Rails 8 gives you
Running
rails newon Rails 8.1.3 creates exactly two things aimed at push:app/views/pwa/manifest.json.erbandapp/views/pwa/service-worker.js. Both are rendered byRails::PwaController, fourteen lines in railties that callskip_forgery_protectionand render each template withlayout: false.What the generator does not do is turn any of it on. The generated
config/routes.rbcarries this, commented:The generated
app/views/layouts/application.html.erbcarries the matching link tag, also commented. Andservice-worker.jsas shipped is twenty-five lines of comment: apushlistener that callsself.registration.showNotification(title, options), and anotificationclicklistener that walksclients.matchAlllooking for a window already on the notification's path before falling back toclients.openWindow.So Rails has decided where the code goes and written the boring half of it, which is genuinely useful, and has implemented nothing. Uncommenting those three lines gets you a service worker that registers and a manifest that serves. It does not get you a key pair, a subscriptions table, or a single byte sent to a push service.
The protocol is three RFCs, and the payload budget is 4096 bytes
Web push is not one specification. RFC 8030, "Generic Event Delivery Using HTTP Push", defines the transport: your server POSTs to an endpoint URL that the browser vendor owns, and the push service delivers to the browser. RFC 8291, "Message Encryption for Web Push", defines how the body is encrypted so the push service relays something it cannot read, using ECDH on the P-256 curve and AES-128-GCM. RFC 8292, "Voluntary Application Server Identification (VAPID) for Web Push", defines how your server proves it is the same server the user subscribed to.
The number worth writing down comes from section 7.2 of RFC 8030: "Push services MUST NOT return a 413 status code in responses to an entity body that is 4096 bytes or less in size." Above that you are at the mercy of the push service, and the encryption overhead comes out of the same budget.
Which settles a design question before you meet it. Do not push the notification's content. Push a record id and a path, let the service worker draw a title and a body short enough to fit a lock screen, and let the click go and fetch the real thing. A push message is a doorbell.
VAPID keys, and what rotating them costs
Generating the pair is one call:
The public key is handed to the browser as
applicationServerKey. MDN describes it as "a Base64-encoded string or ArrayBuffer containing an ECDSA P-256 public key that the push server will use to authenticate your application server", and adds the correction people need twice: "This key IS NOT the same ECDH key that you use to encrypt the data." The ECDH keys are thep256dhandauthvalues the browser gives you per subscription.VAPID also wants a contact. RFC 8292 says the
subclaim "SHOULD include a contact URI for the application server as either a 'mailto:' (email) or an 'https:' URI", which is how a push service reaches a human when your server starts behaving badly. A real address, notmailto:noreply@.The cost of the choice, and it is the one nobody prices: the key pair is permanent. A subscription is created against one
applicationServerKey, and MDN is explicit that from then on "all messages from your application server must use the VAPID authentication scheme, and include a JWT signed with the corresponding private key". Sign with a different key and the push service refuses, which the gem surfaces asWebPush::Unauthorized. So losing the private key does not mean regenerating it, it means every subscription you have ever stored is now undeliverable and every user has to opt in again. Put it in credentials, back upconfig/master.key, and treat it like the Rails secret key base.The service worker only controls what sits under its own path
Service worker scope is the trap that costs an afternoon, because the registration succeeds and the push simply never arrives. MDN states the rule plainly: the default scope is "the directory where the service worker script is located", and "a service worker can't have a scope broader than its own location, unless the server specifies a broader maximum scope in a
Service-Worker-Allowedheader on the service worker script".That is the whole reason Rails routes the file at
/service-workerrather than letting it through Propshaft. Serve the same JavaScript from/assets/service-worker-a1b2c3.jsand its scope is/assets/, so it controls nothing a user will ever visit, and the digest in the filename changes the registration's identity on every deploy for good measure.Uncomment the route, register it from the page, and keep the path at the root:
The push handler in the Rails template is already the right shape. The one thing to add is that the
optionsobject it passes through toshowNotificationis wheredata: { path: "/notifications" }has to live, because thenotificationclicklistener underneath it readsevent.notification.data.pathand does nothing useful if that key is missing.PushSubscription is a table you write yourself
Three columns carry a subscription, and all three come from the browser:
The unique index goes on
endpoint, not onuser_id, and that is the modelling decision the whole feature rests on. A subscription identifies a browser profile on a device, so one person with a laptop, a phone and Firefox at home is three rows. Sending means iteratinguser.push_subscriptionsand accepting that some of them are dead.Upsert on the endpoint rather than creating. A returning visitor's page calls
registration.pushManager.getSubscription(), gets the subscription that already exists, and posts it to you again; a naivecreate!either duplicates the row or raises on the index you just added, on a perfectly ordinary second visit.Sending is one call, and it belongs in a job
The gem is
web-push, currently 3.1.0, released 2025-12-18. Maintenance is worth checking before you depend on it: the name most tutorials still use iswebpush, zaru's original, and the gem above is Pushpad's fork of it, which describes itself as carrying "many improvements, bug fixes and frequent updates". Read the repository before you copy a Gemfile line from a 2019 blog post.ttldefaults to 2419200 seconds, which is twenty-eight days of a push service holding a message for a device that never comes back online. For anything time-sensitive that default is wrong, and 600 is a more honest number for a notification that says something just happened.Every call is a synchronous HTTPS request to a third party, one per subscription, so this runs in a job and never in a request. Three separate timeout options exist for exactly that reason:
ssl_timeout,open_timeoutandread_timeout.The 410 that means delete the row
Push services expire subscriptions on their own schedule: a browser reinstalled, a profile cleared, a service worker unregistered. The way you find out is the response code, and the gem maps codes to classes in
lib/web_push/request.rb:WebPush::ExpiredSubscriptionWebPush::InvalidSubscriptionWebPush::UnauthorizedWebPush::PayloadTooLargeWebPush::TooManyRequestsBoth of the first two mean the same thing operationally, and the handler is three lines:
Skip that rescue and nothing breaks loudly. The job raises, Solid Queue retries it, the retry raises the same way, and a year later the table is mostly corpses with a bounded amount of real work buried in them.
Here is the test that stays green while this is broken. A request spec that stubs
WebPush.payload_sendand asserts the job was enqueued passes whether or not the rescue exists, because the stub never returns a 410. The only spec that catches it is one that makes the stub raiseWebPush::ExpiredSubscriptionand then asserts the row is gone. Write that one.Asking for permission is a thing you get to do once
Notification.requestPermission()resolves to"granted","denied"or"default". What makes it unlike every other browser prompt is that"denied"is terminal: the decision is remembered for the origin, later calls resolve to"denied"without drawing anything, and nothing in your JavaScript can reopen it. The user has to go into browser site settings, which nobody does.Two consequences follow. The call has to happen inside a real user gesture, MDN's example puts it in a click handler, and browsers increasingly refuse it otherwise. And asking on page load, before the visitor knows what your product notifies about, spends the only ask you have on somebody with no reason to say yes.
The position, and what would change it: ask from a settings screen, behind a control the user deliberately switched on, never from a banner on first paint. The cost is that a screen has to be built and most people will never find it, so your opt-in rate will look bad next to the ask-everyone approach. The ask-everyone approach converts better for a week and then owns a permanently blocked origin, and there is no migration back from that.
One more flag belongs in the subscribe call. MDN:
userVisibleOnly"is required in some browsers like Chrome and Edge. They will reject the Promise ifuserVisibleOnlyis not set totrue." Set it, and accept what it means: every push must produce a visible notification. Silent background pushes are not available to you.iOS wants the web app on the Home Screen first
Safari on iOS is the constraint that decides whether this feature is worth building at all, and it is not a bug you can code around. Apple shipped web push in iOS and iPadOS 16.4, and the WebKit announcement states the condition: "A web app that has been added to the Home Screen can request permission to receive push notifications as long as that request is in response to direct user interaction."
A page open in a Safari tab, therefore, gets nothing. Not a prompt, not a subscription. The user has to hit the share sheet, pick Add to Home Screen, open the resulting icon, and only then can your button ask. Which means
app/views/pwa/manifest.json.erb, the file that looked like a formality, becomes load-bearing: the manifest has to be served, linked from the layout, and declaredisplayasstandaloneorfullscreenfor the thing to install as a web app at all. Both of those lines are commented out in a fresh Rails app.Put a number on the funnel before you commit. On iOS, a push subscription costs you an install step that most visitors will not complete, on top of a permission prompt they can still refuse. If your audience is mobile and mostly iPhone, in-app notifications plus email will reach more people than web push will, for a fraction of the work.
What this page leaves out
Native mobile push is a different stack entirely. APNs and FCM through their own SDKs have no overlap with anything above except the word "notification", and nothing here helps you ship an iOS or Android app.
Delivery receipts do not exist. The push service accepts your POST with a 201 and tells you nothing about whether a device was awake, whether the notification was drawn, or whether anybody saw it. Any read tracking has to come back through a
fetchyou put in the service worker yourself.And the notifications module in this codebase implements none of it. The
PushSubscriptiontable, the VAPID credentials, the job and the rescue are all work you would be adding. The in-app side is the part that is already built and tested, and it is the prerequisite either way: web push tells somebody something happened, and the list they open afterwards still has to exist.