◉RORK LABJP
●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●SDK 58 — SDK 58 Beta has been out since Sep 15. The stable date is still unconfirmed●RN 0.88 — React Native 0.88.x is scheduled for Oct 12, 3 days left●Q&A — People are asking why expo-widgets render blank only in production builds●RORK — GPT-6.1 Sol was added on Sep 29, available on Pro and Max plans●NEW — Building an app for a client? Decide who owns the publishing account first●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●SDK 58 — SDK 58 Beta has been out since Sep 15. The stable date is still unconfirmed●RN 0.88 — React Native 0.88.x is scheduled for Oct 12, 3 days left●Q&A — People are asking why expo-widgets render blank only in production builds●RORK — GPT-6.1 Sol was added on Sep 29, available on Pro and Max plans●NEW — Building an app for a client? Decide who owns the publishing account first
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.

Rork577Expo213push 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 $15 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-10-07
Japanese Headings Won't Turn Bold on Android: How to Trace and Fix a fontWeight That Does Nothing
A troubleshooting note for Rork apps where headings look bold on iPhone but stay thin on Android. How to tell the symptoms apart, pick weights through fontFamily, and verify the fix.
⬡ Dev Tools2026-09-21
A build that stops with exit code 0 — the flag that silenced the log, and the check that let an empty file through
Two reports landed in the same week: eas build --local stopping with exit code 0 but produced no further output, and a zero-byte stub that never got embedded. Both came down to settings that reduce output. Here is how to audit your own scripts.
⬡ Dev Tools2026-09-07
Adding .easignore stops EAS from reading .gitignore — count what actually ships to the build
EAS Build decides what to upload from .gitignore, and the moment you add .easignore the two swap places. Here is how I count the bundled files before sending, plus a measured result: once a parent directory is excluded, an exclamation mark cannot bring a file back.
📚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