Privacy-First Reminders: Web Push Where the Schedule Never Leaves the Device

CareReady keeps every family schedule on the device. Reminders threatened that promise — so we inverted Web Push: the server sends an empty daily wake-up, and the Service Worker decides on-device whether to notify.

CareReady is a belongings checklist for family caregivers. Everything a user enters — who they’re preparing for, where they’re going, when — lives on the device, in IndexedDB. No account, no server copy. That was a deliberate promise, and it made one feature awkward: reminders.

A useful reminder is time-based. “Your mother’s short-stay is in two days — want to start packing?” The obvious way to send that is a scheduled server job: the client uploads the outing date, a Lambda wakes up daily, finds the dates that are close, and pushes a notification. Simple. It also quietly breaks the promise: now the server knows when and where every user is going.

So we inverted it.

The trick: a payload-less “wake-up” push

Web Push lets a server deliver a message to a browser even when the tab is closed, via the browser’s push service. The message can carry an encrypted payload — or nothing at all. A payload-less push still wakes the Service Worker; it just arrives with event.data === null.

That “nothing at all” is the whole design:

  • The server stores only an anonymous push subscription (an opaque endpoint + keys). It knows nothing about outings.
  • A daily job sends the same empty “check” push to every subscription.
  • On receipt, the Service Worker reads the local data itself and decides whether there’s anything worth showing.

The schedule — the sensitive part — never leaves the device. The server is a dumb daily alarm clock.

// service worker
self.addEventListener('push', (event) => {
  event.waitUntil(maybeNotify());
});

async function maybeNotify() {
  const outings = await idbGet('careready', 'kv', 'specialOutings');
  if (!Array.isArray(outings)) return;

  const today = new Date(); today.setHours(0, 0, 0, 0);
  let soonest = null;
  for (const o of outings) {
    if (!o.date) continue;
    const days = Math.round((new Date(o.date + 'T00:00:00') - today) / 86400000);
    if (days >= 0 && days <= 2 && (!soonest || days < soonest.days)) {
      soonest = { name: o.name, days };
    }
  }
  if (!soonest) return; // most days: stay silent

  const when = soonest.days === 0 ? 'today' : soonest.days === 1 ? 'tomorrow' : `in ${soonest.days} days`;
  await self.registration.showNotification('CareReady', {
    body: `${soonest.name} is ${when}. Shall we start packing?`,
    tag: 'careready-outing',
  });
}

The Service Worker can open the same IndexedDB the app uses, so the decision logic runs entirely on-device.

What you trade

Nothing is free. Two costs are worth naming:

  1. You push to everyone, every day. The server can’t pre-filter by date, so it fans out a daily no-op to every subscriber. Push services are built for this volume and the payload is empty, but it’s less efficient than a targeted send. For a small user base it’s a rounding error; at scale you’d revisit it.
  2. The reminder logic lives in the Service Worker, coupled to your storage layout. A schema change in the app has to stay compatible with what the SW reads. We keep that surface tiny — one key, one shape.

Keys and secrets

Web Push needs a VAPID key pair. The public key ships in the client (it’s meant to be public). The private key signs the push and must never touch the repo — it goes in a secrets manager, read only by the sending function. If you generate keys with a one-liner and paste the private one into a config file “just for now,” you’ve already lost; treat it like any other signing key.

Why bother

For a care app, “we never learn your family’s schedule” isn’t a marketing line — it’s the reason a nervous caregiver trusts you with the boring, private logistics of someone else’s day. The reminder is a small feature. Keeping its data on the device is the point.

Takeaway: if a reminder only needs the server to say “check now,” don’t let it say anything more.

This is part of building CareReady, an in-development web app at VEAI LAB. The implementation is public in the CareReady repository; my broader technical-PM profile is at hire-veai.com.

Thanks for reading — built with care, for caregivers.