devsnack

Order Status over FCM: The Notification Is Not the Source of Truth

Messages collapse, arrive out of order and never arrive at all. Send the order id and refetch, why Apple rejects the priority you set, and the token rotation that fails quietly.

DevSnack25 Aug 2026 · 12 min

The first version of order notifications always looks the same. The kitchen accepts, your backend sends a message, and the customer's phone says "Your order has been accepted". It works on the simulator, it works on your own phone, and it works in the demo.

Then it reaches real devices, and the complaints are never that the notification failed to arrive. They are that the app said out for delivery for eleven minutes after the food was on the table, or that a customer saw "delivered" before "picked up". A push message is a hint that something changed, and hints are allowed to arrive late, out of order, or not at all. Anything you put in the payload and render directly is a guess about the past.

Two message types, and only one of them runs your code

FCM sends two shapes. A notification message is handled by the operating system: it is drawn in the tray whether or not your app is running, and on Android your code is not consulted at all while the app is backgrounded. A data message carries only your key-value pairs and is delivered to your handler, which then decides what to show.

Almost every order-status bug starts with sending both at once. A payload containing a notification block and a data block gets split: the tray entry appears immediately, and your handler runs later — when the user taps, or when the system feels like it. The notification the customer reads and the state your app holds are then produced by two different mechanisms with two different clocks.

lib/orders/send-status.ts
await messaging.send({
  token: device.fcmToken,
  // Data only. The tray entry is drawn by our handler, so what the
  // customer reads and what the app believes cannot drift apart.
  data: {
    type: "order_status_changed",
    orderId: order.id,
  },
  android: { priority: "high" },
  apns: {
    headers: {
      "apns-push-type": "background",
      "apns-priority": "5",
    },
    payload: { aps: { "content-available": 1 } },
  },
});

Note that the legacy HTTP and XMPP endpoints are gone. Sending was deprecated in June 2023 and shutdown began in July 2024, so HTTP v1 is the only API, and every tutorial still posting to fcm.googleapis.com/fcm/send with a server key is describing something that no longer exists.

Apple rejects the priority you probably set

The instinct with an order update is to mark everything urgent. On Android that is correct: priority: high is what gets a message delivered while the device is in Doze, and a normal-priority message can legitimately sit until the next maintenance window.

On Apple it is not merely suboptimal, it fails. A background data message must be sent with apns-priority of 5. Send 10 and the FCM backend rejects the request outright with INVALID_ARGUMENT. This is the single most common reason a cross-platform send works on one platform and errors on the other, and because the rejection happens server-side it never reaches the device you are testing on.

Collapsing is a feature, and it eats your intermediate states

FCM does not promise to deliver every message. When a device is offline it holds messages, and if you have given them a collapse key it keeps only the most recent one per key. That behaviour is deliberate and usually desirable — four stale "rider is approaching" pings are worse than one — but it means a phone in a lift can miss picked_up entirely and receive only delivered.

There is a stronger version of the same problem with no collapse key at all. Messages are not ordered. Two sends a second apart can arrive in either order, so a handler that writes whatever the payload says will occasionally write accepted over out_for_delivery and stay there until something else happens to correct it.

This is the same conclusion the subscriptions post reaches about App Store server notifications, for the same underlying reason, and it generalises: treat the message as a signal that something changed and go ask your own backend what the state is now. The payload is a doorbell. It is not the parcel.

Send the id, not the state

Once the message is only a signal, the payload gets small and stops being a versioning problem. It needs enough to identify what changed and nothing more. No status string, no total, no rider name — those are all values that were true when the message was queued and may not be by the time it lands.

lib/orders/push_handler.dart
Future<void> _onMessage(RemoteMessage message) async {
  if (message.data['type'] != 'order_status_changed') return;

  // The payload told us which order to ask about. It did not tell
  // us anything we are willing to display.
  final orderId = message.data['orderId'];
  final order = await api.fetchOrder(orderId);

  await store.upsert(order);
  await notifications.show(titleFor(order.status), bodyFor(order));
}

The tray entry is now drawn from the refetched order, so a message that arrives eleven minutes late produces a correct notification rather than a stale one. A duplicate delivery produces the same fetch and the same result, which makes the handler idempotent without any effort on your part. And an out-of-order pair converges, because both messages ask the same question and the later answer wins.

The cost is one request per message. That is the trade, and it is worth naming honestly: you are buying correctness with a round trip. At delivery-app volumes — a handful of status changes per order — it is not a number anybody will notice.

Tokens rotate, and stale ones fail quietly

The failure that survives longest in production is not the payload. It is a registration token that no longer points at anybody. Tokens change when the app is restored to a new device, when data is cleared, when the app is reinstalled, and sometimes for no reason you will ever see. The send does not error in a way that reaches your logs unless you are looking for it.

lib/notifications/token_registry.dart
FirebaseMessaging.instance.onTokenRefresh.listen((token) {
  api.registerDevice(token: token, deviceId: installId);
});

// Register on every launch as well, not only on refresh. A token
// that changed while the app was uninstalled never fires this stream.
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
  await api.registerDevice(token: token, deviceId: installId);
}

Store tokens against a device identifier rather than only against a user, or the second phone a customer signs into silently replaces the first. When a send returns UNREGISTERED or INVALID_ARGUMENT for a token, delete that row immediately. Keeping dead tokens does not cost money, but it does hide your real delivery rate behind a denominator that grows forever, and the delivery rate is the number you need when a customer insists they were never told.

Assume nothing arrives

Push is a latency optimisation over a system that has to work without it. A user who force-quits the app on iOS will not receive background messages at all until they open it again. A device with notifications denied is in the same position, and on a delivery app that is a meaningful share of customers rather than an edge case.

So the order screen fetches on open, and fetches again on resume. While it is visible and the order is live, it polls on a slow timer — every fifteen or twenty seconds is plenty — and push simply makes the update land sooner than the timer would have. If you build that floor first, every push problem above degrades into a delay instead of a wrong screen, and none of them become a support ticket.

The rule

Send data messages that name what changed and never what it changed to. Read the state from the system that owns it. Everything else in this post — priorities, collapse keys, token rotation, denied permissions — is then a question of how quickly the correct answer arrives, rather than whether the customer is looking at the wrong one.

Feastly — $39

Order status already wired across four apps — customer, rider, restaurant and a Next.js admin — where the push wakes the client and the order is read back from the backend rather than from the payload.

See the live demo