A push notification backend should model users, devices, and push tokens as separate records. A user has many devices, a device has one current token per push service, and tokens change or die without warning. Store each token with its platform and status, send to devices and not to users directly, remove tokens when the provider says they are invalid, and make every send idempotent. Preferences and quiet hours live on the user, and are evaluated at send time.
This article covers the data model, the token lifecycle, quiet hours and time zones, idempotent sends, and a conceptual look at Live Activity tokens on iOS.
What does a push system have to do?
Apple Push Notification service (APNs) delivers to iOS devices. Firebase Cloud Messaging (FCM) delivers to Android, and can also deliver to iOS by relaying through APNs. Your server does not talk to the phone. It sends a request to the provider with a device token, and the provider delivers if it can.
That simple picture hides four problems your backend must solve:
- Knowing which tokens belong to which user, and which are still valid.
- Respecting what each user wants, including when they want it.
- Not sending the same notification twice, or to the wrong person.
- Cleaning up as devices come and go.
How should you model users, devices, and tokens?
Keep three concepts apart.
- A user is a person with preferences.
- A device is an installation of your app on a piece of hardware, with a platform, an app version, and a locale.
- A push token is the address the provider gave that installation. It is a property of the device, and it can change.
A starting schema in PostgreSQL:
create table devices (
id uuid primary key default gen_random_uuid(),
user_id uuid references users (id) on delete cascade,
platform text not null check (platform in ('ios', 'android')),
install_id text not null, -- generated by the app, stable per install
app_version text,
locale text,
time_zone text, -- IANA name, e.g. 'Europe/Berlin'
last_seen_at timestamptz not null default now(),
unique (install_id)
);
create table push_tokens (
id uuid primary key default gen_random_uuid(),
device_id uuid not null references devices (id) on delete cascade,
provider text not null check (provider in ('apns', 'fcm')),
token text not null,
status text not null default 'active', -- active, invalid
created_at timestamptz not null default now(),
invalidated_at timestamptz,
unique (provider, token)
);
A few notes on the choices.
user_id on the device is nullable, because a person may grant notification permission before signing in. When they sign in, you attach the device to the user. When they sign out, detach it, so the next person on a shared device does not receive the previous user’s notifications. This is a privacy issue as much as a technical one.
install_id is an identifier your app generates and keeps. It lets you recognize the same installation when its token changes, so you update a row and do not create a duplicate. Do not use hardware identifiers for this.
The unique constraint on (provider, token) makes registration idempotent and prevents the same token from belonging to two users. If a token shows up under a different device row, for example after a reinstall that created a new install id, move it to the new row and invalidate the old association.
What is the token lifecycle?
Tokens are not permanent. Treat them as hints about where to deliver, always subject to change.
Registration. On launch, the app asks the OS for a token and sends it to your server together with the install id. Do this on every launch, not just the first, because the token may have changed. The server upserts the row and updates last_seen_at. On iOS the app must request notification permission first, and on recent Android versions it must request a runtime permission before it can post notifications. Check the current platform documentation, as the rules have changed over versions.
Rotation. Tokens can change after a reinstall, a restore, an OS update, or when the provider refreshes them. The app is told through a callback and should send the new token. Keep the old token marked invalid once the new one arrives for that device.
Invalidation. Providers tell you when a token is no good. APNs responds with an error for a token that is not registered anymore, such as for an uninstalled app (handling notification responses from APNs). FCM returns an error for an unregistered token as well, and publishes guidance on managing registration tokens. The exact error names and status codes belong to each provider’s documentation, so look them up there. The rule on your side is simple: when the provider says the token is permanently invalid, mark it invalid and stop sending to it. Do not retry those.
Staleness. Even without an explicit error, a device that has not checked in for a long time is probably gone. A cleanup job can mark tokens as stale if last_seen_at is older than a threshold you choose, and delete old invalid rows after a retention period.
Distinguish permanent errors from transient ones. A token error means remove it. A server error or a rate-limit response from the provider means retry later, with backoff. Mixing the two up is how teams either lose users’ notifications or hammer a provider.
How do preferences work?
Preferences belong to the user, with optional per-device overrides. Model them in categories your product actually has, such as messages, reminders, and marketing, and not as one on/off switch.
create table notification_preferences (
user_id uuid not null references users (id) on delete cascade,
category text not null,
enabled boolean not null default true,
primary key (user_id, category)
);
Two rules help. First, store the user’s choice even if the OS permission is off, so the settings screen can explain which one is blocking delivery. Second, evaluate preferences at send time, not at enqueue time. A notification scheduled for tomorrow should respect a preference the user changes tonight.
Legal requirements for marketing messages vary by region, so treat consent for that category as its own explicit field and consult the rules that apply to you.
How do quiet hours and time zones work?
Quiet hours are the most common reason people turn notifications off. Get them right.
Store the user’s quiet window as local times plus a time zone, not as UTC times.
alter table users
add column quiet_start time, -- e.g. 22:00
add column quiet_end time, -- e.g. 07:00
add column time_zone text; -- IANA name
Time zones are the hard part, for three reasons:
- The window can cross midnight. 22:00 to 07:00 is a single window that spans two dates. Handle the wrap in code.
- Daylight saving time moves the offset. Convert with an IANA time zone database at send time. Never store a fixed offset, since it will be wrong for part of the year.
- Users travel. The device reports its current time zone on each launch. Decide whether quiet hours follow the user’s home zone or the current location, and keep the choice consistent.
When a notification falls inside quiet hours, you have options: drop it, delay it until the window ends, or send it silently. The right answer depends on the category. A delayed marketing message is fine. A time-sensitive alert may need to bypass quiet hours, which should be a deliberate, narrow exception, because iOS and Android both have their own interruption levels and focus modes that users control.
For delayed sends, store a deliver_after timestamp in UTC on the job and let a scheduler pick it up. Compute it once when you decide to delay, using the zone data at that time.
How do you make sends idempotent?
Your send path will run more than once for the same event. A worker crashes after calling the provider and before recording success. A queue redelivers. A deploy restarts a job. Without protection, users get duplicate notifications.
Give every logical notification a stable id, built from the event that caused it, such as message:8841:recipient:3f1c. Record it in a table with a unique constraint before sending.
create table notification_log (
dedupe_key text primary key,
user_id uuid not null,
category text not null,
created_at timestamptz not null default now(),
status text not null default 'pending' -- pending, sent, failed, skipped
);
The flow is:
- Insert the row with
on conflict do nothing. If nothing was inserted, another worker already owns this notification, so stop. - Resolve the user’s active devices and tokens.
- Apply preferences and quiet hours. Mark the row
skippedwith a reason if blocked. - Send to each token, handling errors per token as described above.
- Mark the row
sentorfailed.
The remaining gap is a crash between step 4 and step 5, which could cause a resend. Both providers offer a collapse or replacement identifier, so a repeated send of the same notification replaces the earlier one on the device rather than stacking. On APNs this is the apns-collapse-id header (sending notification requests to APNs), and on FCM a collapse key (FCM message lifetime and collapsing). Use them, and set them from the same dedupe key. Check the provider documentation for limits on their format.
Also set an expiry on time-sensitive messages. A “your ride is here” notification that arrives two hours late is worse than none, and both providers let you set how long they should hold it.
How do you fan out to many devices?
A user with a phone and a tablet has two tokens. Decide your policy: send to all active devices, or only to the most recently active one. For messages, all devices is usual, with the OS or app deciding what to display. For single-device actions, such as a one-time code, target the device that asked.
For large sends to many users, put the work on a queue. Break it into batches, send in parallel within the provider’s limits, and record results per token. Keep the payload small, since providers limit payload size, and put detail behind an id the app fetches when it opens. This also avoids putting private content on the lock screen.
What are Live Activity push tokens?
Live Activities on iOS show updating information, such as a delivery or a score, on the lock screen. They add a second kind of token. In addition to the device’s regular push token, each Live Activity instance can have its own push token, which your server uses to send updates to that specific activity.
Conceptually, this changes your model in three ways:
- The token belongs to an activity, not only a device. Model it as a child of the device, with the activity’s identifier, a type, and the entity it tracks, such as an order id.
- It is short-lived. The token exists while the activity runs and is useless once it ends. The app reports new tokens as they are issued, and the server must discard them when the activity ends or the provider rejects them.
- Updates use different delivery rules. They go through APNs with the
liveactivitypush type and a topic suffix for activities. Each payload carries an event (updateorend, andstartfor push-to-start on newer iOS versions) plus the activity’s content state. The details depend on the iOS version, so follow Apple’s guide to updating Live Activities with push notifications.
A table like live_activity_tokens(device_id, activity_id, entity_id, token, status) is enough to start with. Keep the same invalidation and idempotency rules as for regular tokens.
Where can a platform help?
Managed services exist for sending, and some teams use FCM for both platforms to keep one integration. Others write directly to APNs. Either way, the data model above stays the same, because it describes your product’s rules and not the provider’s API.
How does this relate to Edict?
Edict is a mobile backend in development. Its planned push notifications module is designed to handle device registration, token records, and preferences on the backend, and to send through APNs and FCM. Scheduled and event-triggered sends would run on the planned background jobs module. None of this is available yet, and details may change.
Two example apps lean on this. The productivity and task management example sends reminders, and the appointment booking example notifies about confirmations and changes. For an Expo-specific walkthrough, read backend for an Expo app: auth, subscriptions and push. For the job side of reminders, read mobile background jobs: reminders, retries, queues.
Key takeaways
- Model users, devices, and tokens separately. Tokens belong to devices and change over time.
- Register the token on every launch, upsert by install id, and detach devices on sign-out.
- Remove tokens when the provider reports them permanently invalid, and retry only transient errors.
- Store preferences by category and evaluate them, with quiet hours, at send time using IANA time zones.
- Give every notification a dedupe key, and use collapse identifiers and expiry.
- Treat Live Activity push tokens as short-lived children of a device.