AcornReply
← All posts

Web push without native apps

8 min read
  • #pwa
  • #web-push
  • #nextjs

Acorn Reply is a support inbox you're meant to run from your phone. You're between meetings, a customer replies, you want to know — and you want to tap the notification and land on the conversation. That's table stakes for an inbox. The interesting question is how you deliver it.

The default answer in 2026 is still "ship native apps." We didn't. Acorn's notifications run entirely through the web: it's a Progressive Web App, you add it to your home screen, and pushes arrive over the Web Push protocol. No App Store, no TestFlight, no second codebase. This post is about why that's a reasonable trade and what the implementation actually has to get right, because "just send a push" hides more than it lets on.

Why not native?

Native apps buy you things. They also cost a review queue between you and your users, a separate build and release pipeline, and — for a small team — a whole second surface area to keep at parity with the web app you already maintain.

For an inbox, the native advantages mostly don't pay rent:

  • Install friction is real but cheap to clear. "Add to Home Screen" is one step, and the result looks and launches like an app.
  • Notifications don't require native. Web Push delivers to the OS notification center on Android and on iOS (for home-screen-installed PWAs). Tap-to-open works.
  • Shipping is instant. A fix is a deploy, not a resubmission. When the thing you're shipping is "tell me when a customer writes back," being able to fix delivery the same afternoon matters more than a native widget.

So the bet is: a PWA gets us most of the native notification experience for a fraction of the maintenance, and the part it gives up isn't what an inbox needs. The rest of this post is the part you don't see in the demo — the server side of making web push dependable.

The shape of a Web Push send

Web Push is an open protocol — defined across a handful of IETF RFCs, with the browser-side surface specified by the W3C. The server encrypts a payload and hands it to the browser's push service (the endpoint lives on Google, Apple, or Mozilla infrastructure depending on the browser), authenticating itself with a VAPID key pair. The browser wakes a service worker, which shows the notification. You don't talk to the device; you talk to its push service, and it talks to the device.

On the server we wrap the web-push library. The first thing the wrapper does is refuse to pretend it's configured when it isn't:

Configure VAPID once, or bail (pseudocode)
configured = false
 
function ensure_configured():
    if configured: return true
    if any of (vapid_subject, vapid_public_key, vapid_private_key) is missing:
        return false                       # dev without keys
    set_vapid_details(subject, public_key, private_key)
    configured = true
    return true

This tiny gate earns its keep in development. Generating and wiring VAPID keys is a one-time chore nobody wants blocking pnpm dev, so when the keys are absent the channel reports a clean no-op success rather than throwing:

Degrade without keys (pseudocode)
if not ensure_configured():
    # pretend the send succeeded so callers don't loop —
    # but DON'T report the subscription as dead
    return { stale: false }

The detail that matters is the value it returns. A dev-mode skip must not look like a dead subscription, or the next bit of logic will happily prune real subscriptions off real devices. "Pretend success, report not stale" is the only safe lie here. Email still goes out through its own channel, so developers aren't flying blind — push just sits out until someone bothers to set the keys.

Keep the payload tiny

Push services cap payload size — the practical ceiling is around 4KB after encryption. That's not a budget to optimize against; it's a budget to stay nowhere near. A notification has exactly one job: say enough to make you tap, then get out of the way. So the payload carries three fields and nothing else:

Send one push (pseudocode)
payload = { title, body, url }             # three fields; stay well under ~4KB
send_push(
    subscription = { endpoint, keys: { p256dh, auth } },
    payload,
    ttl = 24 hours,                        # how long to retry if the device is offline
)

title and body are what you read; url is where the tap lands. The real content lives in the app — the push is a doorbell, not the conversation. The 24-hour TTL tells the push service how long to keep trying if the device is offline: a day-old "you have a new message" is still useful; a week-old one is noise.

Dead subscriptions are normal — prune them

Push subscriptions die constantly and silently. People clear site data, uninstall the PWA, or the browser rotates the endpoint. You don't get a callback when that happens. You find out the next time you try to send, in the form of a 404 or 410 from the push service.

So every send treats those two statuses as a signal, not an error:

Treat a dead subscription as a signal (pseudocode)
try:
    send_push(subscription, payload, ttl)
    return { stale: false }
catch error:
    if error.status in (404, 410):
        return { stale: true }             # gone — caller prunes it
    raise                                   # transient (e.g. 5xx) — keep it

{ stale: true } tells the caller "this subscription is gone, clean it up." Any other error rethrows — a transient 5xx from the push service shouldn't get a live subscription deleted. Returning a small result object instead of throwing on the expected case keeps the dead-subscription path ordinary control flow, which is exactly what it is.

Pruning the subscription, keeping the preferences

When we do prune, we remove only the subscription, not the user's record of the device:

Prune the handle, keep the device (pseudocode)
function delete_push_subscription(id):
    # remove ONLY the subscription handle. The device row — its label and
    # per-workspace settings — survives, so re-subscribing from the same
    # browser doesn't reset the user's preferences.
    db.delete(push_subscriptions where id == id)

The model splits in two on purpose. A user_push_devices row is the durable thing — "Safari on my MacBook," with its per-workspace mute settings. The push subscription is the disposable cryptographic handle that browsers love to rotate. When a handle dies, the device and its preferences shouldn't die with it; the user re-grants permission and picks up exactly where they were. Tie those two together and every endpoint rotation silently resets someone's notification settings.

The write you don't want to make every time

Here's a problem that only shows up under load. We like to track when a device was last reached, to reason about which are alive. The naïve implementation writes last_seen_at on every successful push. On a busy inbox that fans a flurry of notifications to the same device in a minute, that's a burst of near-identical writes to one row — pure amplification for a timestamp nobody needs to the second.

So the bump is debounced, per device, in process memory:

Debounced last-seen (pseudocode)
last_seen = {}                 # device_id -> timestamp, process-local
DEBOUNCE = 60 seconds
 
function bump_last_seen(device_id):
    now = current_time()
    if device_id in last_seen and now - last_seen[device_id] < DEBOUNCE:
        return                 # already wrote within the last minute — skip
    last_seen[device_id] = now
    db.update(device.last_seen_at = now)

At most one last_seen_at write per device per minute, no matter how many pushes go out. The cache is module-local, so it's per-process. Under a multi-process deploy the worst case is a little extra write amplification — a few processes each writing once a minute instead of one — which is a fine thing to lose, because last_seen_at is a coarse liveness hint, not a ledger. We didn't reach for Redis to coordinate a timestamp that's allowed to be approximate.

In the real implementation the clock and the database write are injectable, so the debounce can be tested without waiting on a real minute or touching a database. Time-based behavior you can't fast-forward in a test tends to get tested by hope.

What "no native app" actually costs

Being honest about the trade:

  • iOS requires the PWA to be installed. Web Push on iOS (16.4 and later) only works for a PWA added to the home screen, not a Safari tab. That's an onboarding nudge to get right, not a blocker.
  • No notification UI you don't control. No rich native categories, no fully custom sounds. For an inbox, the OS default — title, body, tap-to-open — is what you'd build anyway.
  • Per-platform quirks are real. The push services differ at the edges, which is exactly why the send path is defensive about statuses and payload size.

What we get back is one codebase, deploys instead of resubmissions, and a push send path that's well under a hundred lines of well-behaved server code instead of two native apps. For a small team shipping a mobile-first inbox, "tell me when a customer writes back" is a problem the web platform already solves — once you handle the unglamorous parts: degrade cleanly without keys, keep the payload small, prune what's dead, and don't write to the database more than the truth requires.

Where this is heading

Web Push is still maturing, and most of the rough edges are getting filed down:

  • Declarative Web Push. A newer approach lets the browser render a notification directly from a well-formed payload, without waking a service worker to build it in JavaScript — fewer moving parts, and more reliable delivery when the service worker would otherwise be the weak link.
  • iOS catching up. Home-screen Web Push landed on iOS in 2023, and each release since has narrowed the gap with native (badging, Focus integration). The install-to-home-screen requirement is the main rough edge left — and it's a platform decision, not ours to fix.
  • Richer, safer payloads. As the encryption and delivery specs settle, the temptation is to cram more into the payload. The discipline that keeps a notification a doorbell rather than a database is worth keeping no matter how big the envelope gets.

The throughline is the same bet the whole post makes: the web platform keeps absorbing capabilities that used to require a native app, and for a small team the maintenance saved compounds. Notifications were one of the last genuinely native-only features for an inbox. They aren't anymore.

Further reading