Rebuilding CRLO Reminders as a Backend Workflow
How I moved CRLO vehicle reminders from device-local schedules to a server-owned delivery pipeline with shared recipients, retry state, and a calmer overdue cadence.
projects · architecture · mobile · expo · convex
Scheduling a notification is easy. Owning a reminder is not.
The distinction became important while I was building CRLO, a shared vehicle logbook for families and households. A reminder for an insurance renewal or service deadline is not just an alert on one phone. It is shared product state: it can be edited, completed, or cancelled; it may concern several people; and it still matters when nobody has opened the app recently.
CRLO’s first implementation treated reminders as a mobile concern. The current architecture treats them as a backend workflow. That change added more moving parts, but it put responsibility in the right place.
The first design was reasonable, but its ownership was wrong
The original path was straightforward:
- A React hook read the garage and its reminders from the synced database.
- It compared each reminder with a small in-memory signature cache.
- It used
expo-notificationsto schedule or cancel a local notification. - It wrote the device-generated notification identifier back to the reminder.
This is a sensible design for a personal, single-device utility. Expo explicitly
supports scheduling one-off and repeating local notifications through
expo-notifications.
It is compact, works without a notification server, and keeps the first version
easy to reason about.
The problem was not the API. It was that CRLO’s product model had become shared. Garages can contain several vehicles and collaborators, with records kept in sync across devices. A notification identifier created by one installation does not represent that shared reminder. Another device can neither rely on that identifier nor use it as a durable delivery record.
The old flow also depended on the app running its reconciliation hook. A new or edited reminder only became a local schedule after a device observed it. A collaborator joining later was outside that schedule. Once a reminder became overdue, the client converted it to a daily repeating notification, which was simple but too blunt for a maintenance product.
I had placed durable responsibility at the edge of the system.
The boundary I wanted instead
The replacement follows one rule:
The backend owns when a reminder is due and who should receive it. The device owns permission, token registration, presentation, and navigation.
That boundary fits the domain. A reminder belongs to a vehicle inside a shared garage, so its schedule and recipient set should be derived from the same source of truth. A phone is a delivery endpoint, not the scheduler of record.
| Responsibility | Device-local design | Server-owned design |
|---|---|---|
| Due-time evaluation | Each active installation | One backend schedule |
| Recipients | The device that created a schedule | Current owner and collaborators |
| Retry state | Not represented centrally | Persisted per delivery |
| Overdue cadence | Daily local repeat | Day 1, 3, 7, then weekly |
| Audit trail | A notification ID on the reminder | Delivery status and counters |
This does not make the phone unimportant. The app still requests notification permission, registers its Expo push token with platform and locale metadata, and routes a tapped notification to the correct reminder. It simply no longer owns business time.
A delivery is a state machine, not a boolean
CRLO now scans for due reminders on a 15-minute interval. An indexed
serverNotificationNextAt field makes the next eligible reminders queryable
without loading every reminder. When one is due, the backend creates a delivery
record and schedules the external push action.
The delivery record carries more than sent: true:
- status: pending, processing, sent, cancelled, or failed
- attempt count and last error
- sent, removed-token, and permanent-failure counts
- remaining users and tokens when only part of a send needs another attempt
graph LR A[Active reminder] --> B[Due scan] B --> C[Pending delivery] C --> D[Processing] D -->|Completed batch| E[Sent] D -->|Retryable remainder| C D -->|Reminder changed| F[Cancelled] D -->|Attempt limit| G[Failed] D -. Stale processing .-> C
That state matters because push delivery is an external side effect. Convex can
atomically create data and schedule work from a mutation, but its documentation
correctly distinguishes that from actions, which are not automatically retried
because they may already have caused an external effect. The
scheduled functions documentation
made the architecture explicit: keep the durable transition in mutations, put
the network call in an action, and model retries yourself.
CRLO retries with exponential backoff, capped at 15 minutes and five attempts.
It also recovers deliveries that remain in processing for more than 30
minutes. These are not guarantees that a notification reached a screen. They
are guarantees that the application can explain what it attempted and continue
after a transient interruption.
Partial success changed the retry design
A shared garage makes failure less tidy. Its owner may have two registered devices, a collaborator may have one, and another collaborator may have an old token. Treating the entire delivery as failed would resend successful notifications and create duplicates.
The delivery action therefore sends to recipients independently and collects the results. Successful work stays counted. Permanently invalid device tokens are removed. Only rejected users and retryable tokens are written back as the remaining work for the next attempt.
This is a small detail with a large effect: retries operate on the unresolved subset, not the original batch. Reliability is not “try the same thing again.” It is preserving enough state to avoid repeating what already worked.
Recipients are also resolved when a delivery is prepared, rather than copied onto the reminder when it is created. If the garage membership changes before the due date, the notification follows the current access model.
Cadence is a product decision
The backend initially notifies when the reminder becomes due. If it remains active, the next notifications are scheduled for one, three, and seven days overdue, then weekly.
That sequence is intentionally opinionated. A daily alert is easy to implement, but it turns a useful maintenance signal into background noise. A single alert is calmer, but it is too easy to miss. The decaying cadence keeps early urgency without demanding attention every day.
Storing the next notification time also keeps this policy visible in data. The cron job is only the clock. The reminder record says what should happen next. Changing the cadence later does not require moving ownership back to the client.
Migration meant supporting two realities
Replacing the scheduler was not a flag-day rewrite. Existing reminder records could have an old device notification identifier, no explicit status, or a date-only value created by an earlier version.
The backend temporarily accepts an undefined status as active, retains the legacy identifier long enough for a cleanup mutation to remove it, and normalizes supported date formats before queueing work. Delivery preparation re-reads the reminder, vehicle, and garage; if its due date changed, it was completed, or it no longer belongs to valid records, the queued delivery is cancelled rather than sent with stale data.
That is the production lesson I want to keep: a migration is a period of coexistence. The new model is only safe when old data has an explicit path through it.
Rollout control follows the same principle. An explicit server switch is checked before due work is queued and again before a pending delivery becomes an external side effect. Disabling only the cron would leave already-queued work live; the second check closes that gap.
What this architecture still does not prove
The system has deliberate limits.
First, a 15-minute scan is not exact-to-the-minute delivery. For vehicle maintenance deadlines, that is a reasonable trade for a simple, observable queue. It would be wrong for an alarm clock or medication schedule.
Second, date-only reminders currently normalize to a deterministic UTC time. That avoids ambiguous parsing, but it is not the same as “morning in the user’s timezone.” Preserving calendar intent and applying a recipient-local delivery window would be a stronger future model.
Third, an accepted Expo push ticket is not proof of device delivery. Expo’s own
reliability guidance
recommends checking push receipts and removing tokens reported as
DeviceNotRegistered. CRLO handles ticket-level failures and invalid tokens,
but receipt reconciliation is still the next boundary to close. I would rather
name that gap than label the pipeline “guaranteed.”
The broader rule
Moving CRLO’s reminders to the backend was not mainly a notification upgrade. It was a correction to the domain model.
If a deadline is shared, editable, and expected to survive device changes, it belongs with the shared data. The client should express user intent and deliver the result. The backend should own time, recipients, state transitions, and the evidence of what happened.
That is more infrastructure than a local schedule. It is also the first version I would trust as CRLO grows from a personal vehicle tracker into a shared vehicle logbook.