How do you send a notification to an iPhone from a web app?
HabitFlow used a setTimeout in the page to fire reminders, which on iOS basically never works. Here is what I learned wiring up real Web Push: VAPID, a service worker, Upstash, and a job queue called QStash.
For a while HabitFlow tried to remind you about your habits with a setTimeout running inside the React app. It worked out the milliseconds until 8 a.m., set a timer, and showed a toast when the timer fired. The catch is that the timer only survives while the page is open, and on an iPhone the page is almost never open. People mark a habit done, swipe the app away, and the JavaScript that owned the timer gets torn down within seconds. By 8 a.m. there was nothing left to fire.
So the question I had was: how do you actually deliver a notification to a phone when your app isn't running? The short answer is Web Push, which iOS finally supports for installed home-screen apps as of 16.4. The longer and more interesting answer is that one notification needs about five separate pieces working together, and I had to learn what each one does. This is what I found out building it.
Why can't a timer in the page just do it?
This is the thing that took me a while to accept. A browser tab is not a place you can leave a job running. On desktop Chrome the app might stay in memory for hours, so a timer looks like it works. On iOS Safari the content process gets killed almost the instant the tab loses focus, and the timer dies with it. You can't talk the OS out of this.
I tried two hacks before giving up on the page. Moving the timer into a Web Worker bought me nothing, because workers die when the page does. Firing a tiny fetch every thirty seconds to keep the app awake lasted about ninety seconds on iOS and turned a tester's battery graph red in a day. The honest conclusion is that the reminder has to live somewhere that isn't the user's device. That means a server, which is annoying, because I had built HabitFlow specifically to not have one. But there's no way around it.
What are the five pieces?
Think of Web Push like sending a postcard to a phone. The browser gives you the address (subscribe), you write the address down (store), you decide when to mail it (schedule), you stamp it so the post office trusts it (sign with VAPID and POST), and the phone's mailbox hands it to a little program that puts it on the screen (the service worker). Five stages, one notification. Here's what each one actually is.
Subscribe is the browser asking its push service for a personal endpoint URL. The user has to grant permission first, and on iOS that prompt only appears if they've added the app to the home screen and opened it standalone. In a normal Safari tab Notification.requestPermission just returns 'denied' with no prompt at all. The subscription you get back is a JSON object: an endpoint URL pointing at that device's push service (APNs for iOS, FCM for Android) plus a pair of encryption keys.
Store is just saving that object so the server can find it later. I keep each one in Upstash Redis under a key built from a SHA-256 hash of the endpoint URL, truncated to sixteen characters. Same hash on the way out gives me an O(1) lookup. VAPID is the part that lets a push service trust you: a key pair you generate once with npx web-push generate-vapid-keys. The public key goes in the browser's subscribe call, the private key signs the messages, and they have to match or the push service rejects you.
The piece the user actually feels is the last one. When a push arrives, iOS wakes a service worker (a script the browser keeps around even when the app is closed) and gives it about ten seconds to draw the notification. That handler is twenty lines, and it's worth reading because two small details in it decide whether the notification shows up at all.
// src/sw.js
self.addEventListener('push', (event) => {
if (!event.data) return
let payload
try {
payload = event.data.json()
} catch {
payload = { title: 'HabitFlow', body: event.data.text() }
}
const { title = 'HabitFlow', body = '', url = '/', tag = 'habitflow' } = payload
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: '/icon.svg',
badge: '/icon.svg',
data: { url },
tag,
renotify: false,
})
)
})The event.waitUntil is what keeps the worker alive until the notification has finished rendering. Leave it out and iOS sometimes kills the worker mid-draw, and the user gets nothing. The tag is a dedup key: two pushes with the same tag collapse into one instead of stacking. For task reminders I use something like task-${taskId}-${offsetKey} so a 1-hour-before nudge doesn't pile on top of the 1-day-before one for the same task.
Why did a cron stop being enough?
For the morning digest, scheduling is easy. Everyone gets it at the same time, so one Vercel cron at 7 a.m. UTC fires once a day, walks every subscriber in Redis, and sends. That's a two-line vercel.json entry and a loop. It still runs HabitFlow's morning push today.
Then I added task reminders at arbitrary times, and the cron model fell apart. Pick up dry cleaning at 6:45 p.m. is not something a daily 8 a.m. cron can do. The obvious fix is a cron that runs every five minutes and scans Redis for anything due in the last five minutes. That works, and it's the kind of thing you regret: 288 runs a day to send one or two notifications, paying per invocation, scanning the whole set each time. I wrote it and deleted it.
The thing I was missing is that exact-time scheduling is a solved problem, and the solution is a delayed-job queue, not a busy cron. Upstash's QStash is exactly that: a queue with one job, which is POST this JSON to this URL, not before this timestamp. You publish a message with a notBefore field, Upstash holds it, and at the right moment it POSTs your endpoint. I publish one message per task per reminder offset, and the fire timestamp is computed on the client because the client's clock is the only thing that knows what the user's local 6:45 p.m. means.
How does the server know the request is really from QStash?
This was the one genuinely fiddly bit. When QStash fires, it POSTs my notify endpoint, which sits on the public internet where anyone can curl it. The proof it's really QStash is an HMAC signature in the Upstash-Signature header. The @upstash/qstash library verifies it, but only against the raw unparsed body, and Vercel's default parser eats the body before you can see it. So the first move is to turn the parser off and read the stream yourself.
// api/notify.js
const { Receiver } = require('@upstash/qstash')
module.exports.config = { api: { bodyParser: false } }
function getRawBody(req) {
return new Promise((resolve, reject) => {
let data = ''
req.on('data', (chunk) => { data += chunk })
req.on('end', () => resolve(data))
req.on('error', reject)
})
}
module.exports = async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end()
const rawBody = await getRawBody(req)
const signature = req.headers['upstash-signature']
try {
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY,
})
await receiver.verify({
signature,
body: rawBody,
url: `https://${req.headers.host}/api/notify`,
})
} catch {
return res.status(401).json({ error: 'invalid signature' })
}
// … rest of handler looks up the subscription and sends the push
}Two signing keys, current and next, let Upstash rotate the key without breaking messages already in flight, since the receiver tries both. Once the signature checks out the rest is dull: look up the subscription by endpoint hash and send the push. The morning cron, by contrast, just checks a bearer secret Vercel sets for it. The notify endpoint earns the extra care because it's the one that can ring a real person's phone.
Things that surprised me
A handful of things I didn't expect going in:
- Each piece is tiny, but the contract between them is huge. The subscribe endpoint is twenty lines, the cron is a JSON file, the push handler is twenty lines. The hard part is that the VAPID key, the endpoint hash, the cron secret, and the payload size limit all have to line up across files. Each is a one-line problem. There are about forty of them.
- Subscriptions go stale silently. When someone uninstalls the app, the next send returns 410 Gone or 404. If you don't delete those, the set fills with dead subscriptions and the cron quietly fails for them forever. Two lines of cleanup on those status codes keep it honest.
- I kept the encryption keys out of the queue. The QStash message carries the task text and the endpoint, not the full subscription. If a payload leaked, someone would learn you have a dry-cleaning task and nothing more. The send side resolves the endpoint back to a real subscription from Redis.
- QStash gives you retries for free. Set retries: 3 and a flaky moment means a delayed notification instead of a lost one. A cron can't do that; a missed run is just gone.
- It's not real-time, and it doesn't need to be. Delivery lands within two or three seconds of the scheduled time. For 15 minutes before that's invisible. For right now it would feel slow, but a reminder app never needs right now.
So is it worth it?
Web Push on iOS is real and it ships, and I'm glad I built it. If I started over I'd skip the cron-only version and go straight to QStash plus Upstash from the first commit. Not because the cron was wrong, it still runs the morning digest fine, but because bolting a queue on afterward meant splitting the cron, moving the per-task scheduling to a new endpoint, adding the signature check, and reshaping the Redis data. That was several days I could have skipped.
Here's what I'd tell someone about to try this: assume from day one that every reminder will eventually need an exact time, every subscription will eventually expire, and every payload will eventually need a signature. If that's where you're headed anyway, a job queue plus Redis gets you there without a rewrite. It's worth caring about the moment your app needs to nudge a phone that isn't currently open. Before that, a timer in the page is fine, and you probably don't need any of this yet.


