Treat Push Broadcasts Like Deployments, Not Messages
What building CRLO’s protected broadcast path taught me about previewing blast radius, binding operator intent, preventing duplicate fan-out, and recording partial progress.
projects · architecture · mobile · expo · convex · reliability
Sending one notification looks like a function call. Sending the same notification to every registered device is an operational event.
That difference shaped a protected broadcast path I built for CRLO, the shared vehicle logbook I am developing with Expo and Convex. A broadcast can fan out immediately, retries can create duplicates, and a successful API response can still say very little about what reached a person’s screen.
The implementation made one principle explicit:
Treat a broadcast like a deployment. Show its blast radius, bind the operator to one exact operation, give it an identity, and record what happened.
This is a source-level case study, not a claim about production campaign results. The useful part is the control model around the side effect.
The risk changes when one action becomes many
CRLO already had a push-notification path for individual users. Reusing it for all registered tokens would have been mechanically easy: query the tokens, loop over them, and call Expo.
That design leaves the important questions unanswered:
- How large is the audience right now?
- Did the operator confirm this specific campaign or merely click “yes”?
- What happens if the action is invoked twice?
- Can I see how far it progressed before failing?
- Does “sent” mean Expo accepted the request, a platform received it, or a device displayed it?
Those are not user-interface details. They are part of the backend contract.
The broadcast path therefore separates preview, confirmation, execution, and evidence instead of hiding all four inside one send function.
Preview the blast radius without creating a side effect
The first operation is read-only. It pages through registered push tokens and returns two counts: target devices and distinct users.
Both numbers matter. A user may have more than one device, so a user count alone understates the number of external sends. A device count alone can make the human audience look larger than it is. Showing both gives the operator a more honest picture of the blast radius before execution is even possible.
The preview uses the same paginated audience source as the send path. That avoids one definition of “audience” in the confirmation screen and another in the action. It is still a snapshot: registrations can change between preview and execution. The goal is informed intent, not a frozen recipient list.
This pattern applies beyond notifications. Before a data migration, show the rows and tenants affected. Before a bulk email, show recipients and suppressed addresses. Before an administrative delete, show the records and dependencies that will disappear.
Bind confirmation to the campaign
A generic confirmation such as “Are you sure?” proves very little. It does not identify what is being approved, and it becomes especially weak when an operator has several tabs or commands open.
CRLO’s internal action requires a confirmation value in this form:
SEND:<campaignId>
The backend derives the expected value from the campaign ID and rejects anything else. The confirmation is therefore tied to the same identity used by the execution record.
This is not security by typing ceremony. Access control still has to decide who may reach the internal operation. The typed value is a guard against accidental execution: it makes the operator restate the identity of the side effect they intend to trigger.
The broader rule is useful for any high-impact tool. Confirmation should name the resource, environment, or operation being changed. “Delete production” is stronger than “yes.” “MIGRATE:tenant-42” is stronger than a reusable checkbox.
Make the campaign the duplicate boundary
Before sending anything, the action attempts to create a broadcast record keyed
by campaignId. If that campaign already exists, the action returns the stored
status and counters instead of starting another fan-out.
That changes a retry from “send everything again” into “tell me what already happened for this campaign.” Within this implementation boundary, the campaign ID acts as the idempotency key.
The distinction is important:
| Identity | Answers | Safe retry behaviour |
|---|---|---|
| Request ID | Was this HTTP call repeated? | Deduplicate one transport request |
| Campaign ID | Was this operator intent already executed? | Return the existing broadcast |
A request can be retried under a new connection or process. The campaign is the durable business operation, so that is where duplicate protection belongs.
I have not used this source change to claim tested concurrency behaviour. The verified property is narrower: repeated invocations that find the persisted campaign return its existing record rather than deliberately starting a second broadcast.
Bound the work and persist progress
The action does not load the complete token table into memory. It reads a page of audience records, builds messages, and sends those messages to Expo in bounded chunks. After each page, it stores the continuation cursor and cumulative counters on the broadcast record.
The record moves through three explicit states:
graph LR A[Campaign created] --> B[Sending] B -->|All pages processed| C[Completed] B -->|Unhandled error| D[Failed]
The counters distinguish targeted devices and users from requests accepted by
Expo, invalid tokens removed, permanent failures, and tokens still considered
retryable. That vocabulary is more useful than one sent boolean because it
preserves partial outcomes.
There is an important limit here. Persisting a cursor makes progress visible, but this implementation does not demonstrate resumable recovery from that cursor. A failed campaign is recorded as failed; it is not automatically continued from its last checkpoint. Observability and recovery are related, but they are not the same feature.
Name the provider boundary honestly
Expo accepts an array of at most 100 messages per request, so CRLO chunks the
work at that boundary. The send helper then classifies individual ticket errors.
Tokens reported as DeviceNotRegistered are removed. Errors treated as
permanent are counted as failures. Missing or unclassified ticket results are
kept as retryable and receive one bounded second attempt.
The counter is deliberately named acceptedByExpo, not delivered.
Expo’s push-service documentation
states that an ok push ticket means Expo received the message, not that the
user received it. A later push receipt only establishes that FCM or APNs
accepted it, and even that does not prove display on the device.
CRLO’s broadcast path processes immediate push tickets. It does not yet show receipt reconciliation, exponential backoff for whole-request failures, or proof of device delivery. Those are separate reliability boundaries and should not be smuggled into the meaning of “completed.” In this workflow, completed means that the action processed every audience page and recorded its immediate Expo results.
The review checklist I would reuse
Before exposing another one-to-many administrative action, I would ask:
- Can the operator preview the scope without causing a write?
- Does confirmation identify the exact operation?
- Is there a durable business key for duplicate protection?
- Is work paginated or chunked at known system boundaries?
- Is progress persisted after each bounded unit?
- Do states distinguish sending, completion, and failure?
- Are retryable and permanent errors handled differently?
- Does the result name what the downstream provider actually proved?
- Are recovery gaps visible instead of implied away?
This checklist works for bulk email, account migrations, invoice runs, cache purges, and destructive maintenance jobs. The payload changes; the operational questions do not.
Design the safety system before the send button
The most consequential part of a broadcast feature is not the loop that sends messages. It is the system around that loop: preview, intent, identity, bounded execution, progress, and precise outcome language.
Treating the operation like a deployment made those concerns part of CRLO’s backend model before any convenient admin interface could hide them. That is the order I want to keep. When one action can affect everyone, safety is not a confirmation modal added at the end. It is the architecture of the operation.