RORK LABJP
ACQUISITION — Rork makes its first acquisition, buying Paperline, a macOS app that generates native Swift apps with AIFUNDING — The $15M seed led by Left Lane Capital backs Rork's push to redefine how mobile apps are built and monetizedGROWTH — Rork Max reportedly hit $1.5M ARR within three days of launch and doubled annual revenue in two weeksENGINE — Rork Max runs on Claude Code and Claude Opus 4.6, the first web Swift builder aiming to replace XcodeSPLIT — Standard Rork uses React Native (Expo); Rork Max generates native Swift across the whole Apple ecosystemPRICING — Start free; paid plans begin at $25/month, with Rork Max at $200/monthACQUISITION — Rork makes its first acquisition, buying Paperline, a macOS app that generates native Swift apps with AIFUNDING — The $15M seed led by Left Lane Capital backs Rork's push to redefine how mobile apps are built and monetizedGROWTH — Rork Max reportedly hit $1.5M ARR within three days of launch and doubled annual revenue in two weeksENGINE — Rork Max runs on Claude Code and Claude Opus 4.6, the first web Swift builder aiming to replace XcodeSPLIT — Standard Rork uses React Native (Expo); Rork Max generates native Swift across the whole Apple ecosystemPRICING — Start free; paid plans begin at $25/month, with Rork Max at $200/month
Articles/Dev Tools
Dev Tools/2026-06-25Advanced

When Push Notifications Reach Only Some Users — A Delivery-Reliability and Diagnosis Design for Rork (Expo) Apps

A design for diagnosing why push notifications reach only some users in a Rork-generated Expo app, by splitting send-to-display into stages and measuring delivery to close the gap. Covers stale-token cleanup, recording APNs/FCM failure codes, priority and message type, and a per-user triage procedure, with implementation.

Rork452Expo105push notifications11delivery rateAPNs3FCMtoken managementnotification design

Premium Article

When Push Notifications Reach Only Some Users

I once sent an announcement by push for one of my indie apps and the open count came in far below what I expected. The send log said "sent to everyone." Yet a few people wrote to say no notification arrived, and on one of my own spare devices it hadn't shown up either. Sent but not arriving — at the time I could only explain that vague state as "APNs must be acting up."

What I learned later is that "not arriving" is not a single phenomenon. The token is stale, the server rejected acceptance, the platform throttled it as low priority, the device didn't display it — all produce the same "didn't arrive," but the cause and the fix are completely different. This article shares the design — with implementation — for observing notifications across the stages from send to display, isolating where they drop, and closing the delivery gap.

Decompose "Didn't Arrive" Into Stages

The first move is to split one "didn't arrive" into stages that can each fail independently. Put an observation point at each stage and a vague malfunction becomes "N dropped at this stage."

StageMain reasons it dropsSignal to check
1. Token acquisitionNot permitted / token not registeredDevice permission status, server registration
2. Server acceptanceStale token, bad payloadAPNs/FCM response code
3. Platform deliveryLow-priority throttlingPriority setting, message type
4. Device displayForce-quit, notifications off, silent handlingReceive-handler arrival log
5. OpenUnnoticed / not interestedOpen event

Most developers watch only stage 5 (open rate). But much of "didn't arrive" happens at stages 2–4, and without observing those you can't tell whether a low open rate means "the content is bad" or "it never arrived at all." Below, I knock down the most failure-prone stages in order.

Clean Up Stale Tokens (the Quietest Killer)

In production the most common cause of "didn't arrive" is the server sending to old tokens forever. When a user deletes the app, reinstalls, or updates the OS, the token changes. Send to an old token and APNs/FCM return "invalid" at acceptance or in a later receipt — but if you don't record and clean those up, you keep sending to dead addresses indefinitely.

Design tokens as a pair: not just "register" but "invalidate."

// register-token.ts — device side: register a token and update on change
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
 
export async function registerPushToken(userId: string) {
  if (!Device.isDevice) return; // simulators can't get a real token
 
  const { status } = await Notifications.getPermissionsAsync();
  let finalStatus = status;
  if (status !== "granted") {
    finalStatus = (await Notifications.requestPermissionsAsync()).status;
  }
  if (finalStatus !== "granted") {
    // Drops at stage 1: not permitted. Record on the server as "cannot send"
    await fetch("https://api.example.com/push/unregister", {
      method: "POST",
      body: JSON.stringify({ userId, reason: "denied" }),
      headers: { "Content-Type": "application/json" },
    });
    return;
  }
 
  const token = (await Notifications.getExpoPushTokenAsync()).data;
  await fetch("https://api.example.com/push/register", {
    method: "POST",
    body: JSON.stringify({ userId, token, platform: Device.osName }),
    headers: { "Content-Type": "application/json" },
  });
}

On the server, always record the failure code returned on each send and stop invalid tokens. With Expo's push API, a token whose receipt returns DeviceNotRegistered should be invalidated immediately.

// send.mjs — server side: send, and clean tokens by failure code
import { Expo } from "expo-server-sdk";
const expo = new Expo();
 
export async function sendBatch(messages) {
  const chunks = expo.chunkPushNotifications(messages);
  const stats = { accepted: 0, rejected: 0, invalidTokens: [] };
 
  for (const chunk of chunks) {
    const tickets = await expo.sendPushNotificationsAsync(chunk);
    tickets.forEach((t, i) => {
      if (t.status === "ok") {
        stats.accepted++;
      } else {
        stats.rejected++;
        // Dropped at stage 2. Always keep the reason code
        if (t.details?.error === "DeviceNotRegistered") {
          stats.invalidTokens.push(chunk[i].to); // delete from DB later
        }
        console.error(`reject: ${t.details?.error} token=${chunk[i].to}`);
      }
    });
  }
  // Clean invalid tokens (don't send to these addresses next time)
  await purgeTokens(stats.invalidTokens);
  return stats;
}

The key is keeping accepted and rejected as recorded metrics. Instead of "sent to everyone," knowing "N accepted, M rejected, K of them stale" turns stage-2 loss into a number. I log these three on every send and watch whether the share of stale tokens spikes. A sudden rise usually traces back to a major OS update or a surge of reinstalls.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
Decomposes 'notifications don't arrive' into send → accepted → delivered → displayed → opened, and designs the observation points to isolate where it dropped
Stale-token cleanup, recording APNs/FCM failure codes, and choosing priority and message type — shared as the exact implementation I run in my own indie notification operations
Receiver-side instrumentation to measure delivery as a number, plus a triage procedure that pins the cause within five minutes when a specific user says they didn't get it
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-04-09
Rork × OneSignal: Supercharging Push Notifications in Your App
Learn how to integrate OneSignal into your Rork app for advanced push notifications — covering SDK setup, user segmentation, rich notifications, and delivery analytics.
Dev Tools2026-06-25
Why Paying Members See a Paywall in Airplane Mode — Keeping RevenueCat Entitlements Alive Offline
Open the app on a weak connection and a paying subscriber sees a paywall flash for a second. Here is how RevenueCat's customerInfo wavers on an offline launch, and a cache design that keeps entitlements valid with a trust window — written as working code for an Expo app.
Dev Tools2026-06-24
When EAS Update Ships but the Bug Won't Die — Why OTA Stalls Silently, and How I Operate Around It
EAS Update can succeed and still fail to reach a slice of your users. These are field notes on runtimeVersion drift, updates that publish but never get adopted, and choosing the right rollback — with the instrumentation that actually helped on my Rork apps.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →