RORK LABJP
ENGINE — Rork Max is powered by Claude Code and Claude Opus 4.6, generating native Swift apps directlyCORE ML — Rork Max reaches on-device Core ML inference alongside HealthKit, HomeKit, NFC, and App ClipsSEED — Rork raised a $15M seed led by Left Lane Capital in April 2026, joined by Peak XV and a16z SpeedrunM&A — Rork acquired the app builder Paperline and says it will stay acquisitive to bring in engineering talentMARKET — Gartner expects 75% of new applications to be built with low-code or no-code tools by the end of 2026GROWTH — The no-code AI platform market is projected to grow from $4.9B in 2024 to $24.8B by 2029ENGINE — Rork Max is powered by Claude Code and Claude Opus 4.6, generating native Swift apps directlyCORE ML — Rork Max reaches on-device Core ML inference alongside HealthKit, HomeKit, NFC, and App ClipsSEED — Rork raised a $15M seed led by Left Lane Capital in April 2026, joined by Peak XV and a16z SpeedrunM&A — Rork acquired the app builder Paperline and says it will stay acquisitive to bring in engineering talentMARKET — Gartner expects 75% of new applications to be built with low-code or no-code tools by the end of 2026GROWTH — The no-code AI platform market is projected to grow from $4.9B in 2024 to $24.8B by 2029
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.

Rork498Expo139push 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-07-10
Adding React Compiler to Expo Let Me Delete 41 Hand-Written memo Calls
I enabled React Compiler on Rork-generated React Native screens and measured the rerender counts with Profiler. Here is how I decided which memo and useCallback calls were safe to delete, how to find the components the compiler bailed out on, and how to catch regressions in CI.
Dev Tools2026-07-10
Every Key You Ship Is Public: Secret Boundaries and Rotation for Rork-Generated Apps
Unzip your own .ipa, run strings, and your environment variables are right there in plain text. Here is how I sort keys into three tiers, move the dangerous ones behind an edge proxy, and keep a rotation runbook that assumes leakage.
📚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 →