Postmortem · v1 → v2
I built a PWA that promised to nag you toward your goals. Turns out it could only nag you while you were already staring at it.
TL;DR
The original pitch for NudgeMate was clean: no accounts, no server, no database. Just a PWA, a service worker for offline caching, and localStorage holding your goals. Reminders fired from a plain JavaScript scheduler — find the most-neglected goal, set a setTimeout for the next check-in, fire a notification when it landed.
It worked perfectly. In the browser tab. While the browser tab was open.
Close the app, and that JavaScript context — and every timer inside it — simply stops existing. Nothing was scheduled anymore. Nothing was going to fire. But the app didn't feel broken, because of one deceptive detail: the scheduler re-ran on every launch, checked what was overdue, and fired immediately if something qualified.
22:14 app closed · timers cleared
08:00 reminder was due · nothing happened, nobody home
13:40 app reopened · scheduler runs on mount
13:40 "overdue reminder" found · notification fires instantly
That last line is what fooled me for a while. A notification arriving the moment you open the app doesn't look like a missed reminder — it looks like a reminder that just worked. It took tracing the actual event source to see there was no push listener anywhere in the service worker at all. The app was never receiving anything in the background. It was just very good at pretending, on reopen, that it had.
The v2 wake path
01
Cron job
Vercel Cron checks who's overdue, on a schedule, with no app open anywhere.
02
Push service
Server hands the message to FCM, keyed to a stored subscription — not to the app.
03
Service worker
Wakes on the push event, even if the app was fully killed.
04
Notification
Shown by the worker itself — no page, no tab, no timer required.
"No backend" turned out to be a nice constraint for the UI and a wrong constraint for delivery. The fix wasn't a rewrite — the goal cards, the logging flow, the offline cache all stayed exactly as they were. What changed sat underneath: a small Postgres database for device subscriptions and goal state, a real push handler in the service worker, and a scheduled job that doesn't care whether anyone's phone is even unlocked.
The nudge message bank moved out of a static JSON file and into the database too — not because it needed to, but because the cron job needed to query it without bundling the whole file into a serverless function on every run. Small side effect of the same architecture shift, not a separate decision.
What stuck with me
"Works on desktop" isn't a spec. Desktop Chrome forgives things mobile browsers never will.
A closed tab has no memory. Literally — every variable, every timer, gone the instant it closes.
Ask what wakes the process up before you ask what it should do. The event source matters more than the logic inside it.
"No backend" is a UI ideal, not a delivery mechanism. Something has to be awake when the user isn't looking.
A minimal backend beats a fake-local one. One table, one cron route, no accounts — still true to the original spirit.