Action Push Native: Rails Push Notifications in Food & Fit Without the Glue Code
Push notifications are “easy” until you need them to be boring.
When they’re boring, they arrive on time. They don’t show up twice. They don’t go to the wrong account after someone logs out and back in. And when a provider hiccups, you don’t wake up to a support thread that starts with “I didn’t get the reminder” and ends with “I got it three times.”
That’s why Food & Fit adopted Action Push Native (the Rails gem from 37signals). We already had an Expo app, a device token registration endpoint, and a device_tokens table. What we didn’t have was a Rails-native delivery layer that could carry pushes to APNs/FCM with retries and a consistent payload shape.
This post is the operator-friendly version of what changed and why it mattered.
What we had (and what we didn’t)
Food & Fit already had the “front half” of push notifications:
- a Rails API endpoint for mobile device token registration
- a
device_tokenstable keyed by user - an Expo React Native app
What we didn’t have was the “send half” that you can trust under real conditions:
- a Rails-first delivery path to the providers (APNs for iOS, FCM for Android)
- retry behavior for transient failures
- one consistent place to shape payloads so notifications look the same across features
You can duct-tape this together with provider SDKs, HTTP calls, and custom jobs.
But the duct tape becomes the system. And systems like this tend to break in the same way: quietly, at scale, when you least have time to read a provider’s docs.
Why we chose Action Push Native
Action Push Native gave us a clean Rails mental model:
- define a notification once
- deliver it in the background
- let the platform adapters handle “iOS goes here, Android goes there”
Under the hood, it talks to:
- APNs for iOS
- FCM for Android
The actual win wasn’t novelty. It was fewer one-off scripts and fewer “works on my phone” pushes that fall apart when you add retries, multiple devices, and users who switch accounts.
In short: it made push delivery feel like a Rails feature, not an integration side quest.
The mental model (how a push gets from Rails to a phone)
Here’s the flow I keep in my head. No class names required.
- Build a notification (title/body plus any small bits of metadata you need).
- Ask Rails to deliver it later to one or many devices.
- One background job runs per device.
- If the network or provider has a transient issue, the job retries with backoff.
- iOS deliveries go to APNs; Android deliveries go to FCM.
- Invalid tokens are treated as a token problem, not an app crash.
That’s what makes push delivery predictable: it’s asynchronous, retry-aware, and device-scoped.
The three Food & Fit decisions that mattered
Most of the work wasn’t “send a push.” It was making sure the push system stays calm when users behave like real users.
1) Reuse our existing device_tokens table
We didn’t introduce a new “devices” table. We reused what Food & Fit already had.
Why that mattered:
- lower migration risk
- preserved existing API contracts
- avoided two sources of truth for “where do I send notifications for this user?”
The trade-off is that you need a small compatibility layer so each stored token can behave like a “push device” when it’s time to send. That’s a good trade in a live product because it keeps the data model stable while you improve delivery.
2) Make token ownership safe
Here’s a subtle risk: the same physical device token can end up active for two different accounts.
It happens in the real world:
- someone logs into a shared phone
- someone switches accounts
- the app registers a token again
- the backend naïvely treats “token belongs to this user” as permanently true
If that token stays active across two accounts, you can leak notifications across accounts. Not in an abstract security-theory way. In a “your fasting reminder went to someone else” way.
So we hardened ownership: when a token is activated for the current user, we deactivate that same token anywhere else before marking it active here.
This is one of those changes that makes push feel boring again. Which is the goal.
3) Mark bad tokens inactive (don’t delete)
Push providers will tell you when a token is bad: it doesn’t exist anymore, it’s malformed, or it’s no longer valid for delivery.
You can handle that by deleting the record. We chose a softer approach: mark the token inactive.
Why:
- you keep an audit trail (what tokens existed and when)
- reactivation becomes a normal path (a new token can replace the old)
- you reduce “mystery missing token” debugging when users say, “I turned notifications on”
Operationally, this matters because tokens churn. Devices get reinstalled. OS updates rotate things. People restore phones. The backend needs to treat token changes as expected, not as an error case that forces manual cleanup.
A real feature wired to push: fasting reminders
Food & Fit has fasting sessions. A common pattern is to start a fast and want a reminder shortly before it ends.
The user story is simple:
- start a fasting session
- get a reminder before the target end time
The engineering story is where reliability lives:
- When a fasting session starts, we schedule a reminder job for “end time minus N minutes.”
- When that job runs, it re-checks that the session is still active.
- If the user ended, cancelled, or broke the fast early, the job does nothing.
- If the session is still active, the job sends a push notification to the user’s deliverable device tokens.
That “check again at send time” rule is the difference between a demo and a product.
Jobs retry. Deploys happen. Users change their minds. If you assume the world stayed the same since scheduling, you will annoy users with notifications that are technically correct and practically wrong.
With the guard in place, the reminder delivery is idempotent enough for real usage: retries don’t turn into spam, and fasts that ended early don’t get a stale nudge.
Mobile side: Expo token lifecycle (kept boring)
On mobile, push notification work goes off the rails when token lifecycle is an afterthought. So we treated it like a real feature with a real lifecycle.
At a high level, the app does this:
- request notification permission
- fetch the native device token
- register the token to Rails after login
- store the token locally so we can manage changes
- if the token rotates, register the new token and retire the old one
- on logout, deregister the stored token
The important part isn’t any one API call. It’s that registration and cleanup are tied to authentication state.
If a token outlives the user session, you get weirdness. If it’s synced only “sometimes,” you get gaps. If rotation isn’t handled, you get silent failures.
Boring lifecycle handling prevents those failure modes.
Production checklist (the stuff that breaks first)
This is the checklist we use for the “it worked in development” gap:
- APNs key and identifiers configured for iOS
- Firebase service account present for Android (FCM)
- server secrets and environment variables set correctly in the runtime
- iOS app capabilities include Push Notifications (and the remote notification background mode)
- Android notification permission flow verified on Android 13+
- test on real devices (simulators don’t behave like production push)
If you only do one thing: test on a real iPhone and a real Android device before you trust anything.
Push bugs love the gap between “the build runs” and “the OS actually delivers.”
What we’d improve next
Action Push Native got us to a reliable baseline. The next improvements are mostly product polish:
- a user-facing notification preference UI tied to fasting settings
- deep-link routing so tapping a notification lands in the right screen
- analytics for send attempts, provider errors, and open rates
- an internal “send test push” tool per user for support and debugging
Action Push Native didn’t magically make push notifications simple. It made them legible: a Rails-first delivery layer with retries, clear token failure handling, and a consistent way to send notifications to many devices.
The token ownership hardening is the part I’d do again immediately. It prevents the worst kind of push failure: a message meant for one account landing on another after an account switch.
And the “verify session state at execution time” rule is what keeps reminders useful instead of annoying. In a world with retries and real users, your scheduled job can’t assume yesterday’s state is still true. Keep push boring, and your app gets calmer.
Your AI-built MVP, made production-ready.
Free 15-min call. Paid diagnostic. 1-week sprint with real fixes in production — not a PDF of recommendations.
