Live Notifications Architecture: In-App Delivery, Push, Offline Recovery, and Read State
A live-notification system is more than “send a WebSocket event.” Users can be online or offline, signed in on several devices, deny push permission, reconnect after minutes, and expect unread counts to remain correct across every client.
A production design therefore separates notification state from delivery channels.
The durable notification record answers: what should this user know?
The delivery layer answers: how can we alert them right now?
Start with a durable notification record
Suppose an order is shipped.
Create one durable notification object first:
notification_id: n_900
user_id: u_42
type: order_shipped
entity_id: order_77
title: Your order has shipped
created_at: ...
read_at: null
Then fan that notification out through whatever channels are appropriate:
- open web/app session
- Web Push
- Android/iOS push
- email, if the product requires it
This prevents delivery infrastructure from becoming the source of truth.
If WebSocket delivery fails, the notification still exists. When the user reconnects, the client can fetch it from durable history.
“Sent” is not the same as “delivered” or “read”
A useful notification model distinguishes several states.
created
eligible for channel
attempted
accepted by provider
possibly delivered to device
opened/read by user
Most push providers can tell you that a message request was accepted for delivery. That is not the same as proving a human saw it.
Likewise, a WebSocket write succeeding means the server wrote to an active connection—not that the user read the UI.
Keep read state as an explicit application action.
For example:
POST /notifications/n_900/read
or a bulk marker such as:
mark all notifications created before cursor C as read
Separate realtime transport from durable history
A clean architecture looks conceptually like this:
business event
-> notification service
-> durable notification store
-> delivery fan-out
- realtime connection
- web/mobile push
- optional email
When the user opens the notification center, read from the durable store.
When a new notification is created, use realtime delivery to reduce latency.
That distinction prevents the classic bug where a disconnected user simply loses everything emitted while offline.
Choose WebSocket or SSE from interaction needs
For an open browser session, two common server-to-client transports are WebSockets and Server-Sent Events.
WebSockets
Use WebSockets when the same connection needs frequent two-way realtime traffic such as:
- chat messages
- typing indicators
- presence
- client acknowledgements
- collaborative state
Server-Sent Events
SSE is simpler when the browser mainly needs server-to-client updates.
MDN documents SSE through the EventSource interface and notes that the connection is one-way: server to client.
For a notification bell whose only job is to receive new events, SSE can be enough.
Do not choose WebSockets automatically just because the feature is “realtime.” Choose the simplest transport that matches the interaction model.
Use Web Push for users who are not actively viewing the app
The browser Push API can receive messages even when the web application is not currently open in the foreground.
A Web Push flow typically requires:
- HTTPS
- a service worker
- explicit user permission
- a push subscription
- server-side storage of the subscription endpoint and keys
- push delivery through the browser's push service
The service worker can receive a push event and display a persistent notification through ServiceWorkerRegistration.showNotification().
That is a very different mechanism from an in-page WebSocket or SSE connection.
Treat push subscription endpoints like credentials
A Web Push subscription contains a capability endpoint plus encryption information.
The endpoint should not be exposed casually in logs, client-visible analytics, or URLs.
Store subscriptions under the authenticated user/device record and protect creation/deletion from cross-site request-forgery and authorization bugs.
A user should never be able to register a push endpoint under another user's account.
Ask for notification permission at the right time
Do not request browser notification permission immediately on page load without context.
A better product flow is:
user enables “Notify me about order updates”
then request browser permission
The user now understands why the permission is useful.
MDN's Push guidance recommends that notifications be useful, timely, permission-based, and easy to opt out of.
Notification architecture is partly a UX problem: an technically perfect system that annoys users will simply get disabled.
Store user notification preferences separately from delivery state
A user may want:
security alerts -> in-app + push + email
new chat message -> in-app + push
marketing promotion -> disabled
weekly summary -> email only
Model this explicitly.
For example:
user_notification_preferences
user_id
notification_type
in_app_enabled
push_enabled
email_enabled
Do not scatter preference checks independently across every business service.
Centralize policy so the same event is interpreted consistently.
Distinguish mandatory security events from optional engagement
Some notifications are part of account safety:
- password changed
- new login
- MFA disabled
- payout destination changed
Others are preference-driven:
- recommendations
- promotions
- product tips
Do not let a generic “disable all notifications” switch accidentally suppress critical security communication if the product's safety model requires those messages.
Make policy categories explicit and explain them clearly to users.
Create notifications idempotently
Distributed systems retry.
If an order-shipped event is delivered twice to the notification service, the user should normally receive one notification, not two identical ones.
Use an idempotency key such as:
order_shipped:order_77:user_42
or an upstream event ID with a uniqueness constraint.
The exact key depends on semantics. Two legitimate chat messages should not collapse merely because they have the same text.
Deduplicate the business event, not the rendered message.
Fan-out should be asynchronous for high-volume systems
Creating the business transaction should not usually wait while the server sends push messages to every device.
A safer path is:
business transaction commits
notification event is durably emitted
worker creates/fetches notification
workers fan out delivery attempts
This keeps provider latency and outages out of the customer's core transaction.
Use a transactional outbox or another reliable event mechanism when the business write and notification event must not drift apart.
A naive dual write such as:
save order
publish notification event
can lose the event if the process crashes between those steps.
Store one notification, many delivery attempts
A user can have several devices and channels.
Avoid copying the logical notification once per channel unless the product semantics require separate records.
A useful model is:
notification
n_900 -> user u_42
delivery_attempts
n_900 / web_socket / device_a
n_900 / FCM / phone_b
n_900 / web_push / browser_c
The notification remains one user-facing object while delivery telemetry is channel-specific.
Device tokens and subscriptions expire
Mobile push registration tokens and browser push subscriptions are not permanent identities.
Clients can:
- uninstall the app
- clear browser data
- revoke permission
- rotate tokens
- invalidate subscriptions
Your delivery worker must process provider responses and remove or disable stale destinations instead of retrying dead endpoints forever.
Keep last_seen_at, token/subscription status, and provider errors so cleanup can be automated.
FCM is a transport, not your notification database
Firebase Cloud Messaging routes messages from a trusted app server to platform-specific transports such as Android, APNs, and Web Push.
Use it as a delivery channel.
Do not rely on FCM as the authoritative record of:
- notification history
- unread state
- user preferences
- read receipts
- application business events
Those belong in your application storage.
When a user opens the app after being offline, fetch durable notification state from your API instead of assuming every historical push can be reconstructed from the provider.
Foreground and background behavior should be intentional
When the user already has the app open, showing both an in-app toast and an operating-system push notification can create duplicate noise.
A common policy is:
active foreground session
-> realtime in-app delivery
-> suppress or downgrade push
offline/background
-> push notification
This requires presence/session knowledge that is approximate, not perfect.
Design duplicates to be tolerable anyway. The notification ID should allow the client to recognize that a push and an in-app event refer to the same logical object.
Unread count should come from durable state
Do not maintain an unread badge only as an increment/decrement counter in the browser.
Clients crash, reconnect, log in on multiple devices, and miss events.
The server should be able to answer:
unread_count(user_id)
from authoritative notification state or from a correctly maintained derived counter that can be reconciled.
On reconnect, refresh the unread count instead of trusting the last local number.
Use a cursor for reconnect recovery
A client can keep a cursor such as the last notification ID or creation sequence it has processed.
On reconnect:
GET /notifications?after=cursor
The server returns anything missed while the realtime connection was down.
This gives realtime delivery at-least-current-state behavior without requiring the transport itself to retain every message forever.
If the cursor is too old, the server can simply return the latest bounded page and current unread state.
Realtime events should contain enough identity to refetch
Instead of shipping a giant mutable payload over the realtime channel, an event can contain:
{
"type": "notification.created",
"notification_id": "n_900"
}
The client may render included summary fields immediately, then reconcile with the API as needed.
This is useful when notification content or permissions can change after creation.
Do not put secrets or data the connected user is not authorized to view into broadcast payloads.
Authorization must be checked at connection time and delivery time
A realtime connection is long-lived.
The user's permissions can change while it remains open.
At minimum:
- authenticate the connection
- bind it to the user/session
- authorize subscriptions/rooms
- close or revalidate on session revocation
- never trust a client-supplied user ID for channel membership
For high-risk authorization changes, do not assume a stale socket remains valid until its natural disconnect.
Slow clients need backpressure limits
A server can produce notifications faster than a client can consume them.
Do not let one slow connection accumulate unbounded memory.
Use bounded per-connection queues and a clear policy:
queue full
-> drop redundant ephemeral events
-> or disconnect client
-> client recovers durable notifications through API
This is another benefit of keeping durable notification history outside the realtime transport: disconnecting a slow client does not lose important user state.
Push retries need error classification
Not every failed push should be retried the same way.
Classify failures into categories such as:
- transient provider/network failure
- rate limit
- invalid/expired device token
- malformed payload
- permanent permission failure
Transient failures can use exponential backoff with jitter.
Permanent endpoint failures should disable/remove the destination.
Blind infinite retry creates unnecessary cost and can amplify provider incidents.
Avoid notification storms
A backend incident or batch operation can accidentally generate millions of user-facing notifications.
Protect the system with:
- per-user/channel rate limits
- event deduplication
- aggregation/grouping
- campaign quotas
- kill switches
- provider throughput limits
For example, 40 individual “item updated” notifications may be better rendered as:
> 40 items were updated.
Aggregation logic should follow product semantics rather than blindly combining unrelated security or transactional events.
Observability should separate creation from delivery
Useful metrics include:
- notifications created by type
- deduplication rate
- realtime active connections
- realtime delivery failures
- push requests accepted/rejected
- invalid device/subscription rate
- retry queue depth
- delivery latency by channel
- unread count query latency
- notification open/read rate where product analytics legitimately tracks it
Do not use “push provider accepted message” as your only success metric.
Track the full application pipeline.
A practical notification design
For many products, a clean baseline is:
- Business service commits its state.
- A durable event/outbox records the notification-worthy change.
- Notification service creates one idempotent user notification.
- Preferences determine eligible channels.
- Active sessions receive realtime events through WebSocket or SSE.
- Background/offline devices receive Web Push or mobile push.
- Delivery attempts are logged independently from the notification.
- Client reconnects fetch missed notifications from durable history.
- User read actions update durable
read_atstate. - Every device periodically reconciles unread count from the server.
This architecture keeps the realtime layer fast without making it responsible for durability.
When live notifications are not needed
Do not make every product event realtime.
Polling, refresh-on-focus, or email may be simpler when:
- updates are infrequent
- seconds/minutes of delay are acceptable
- users do not keep the application open
- the operational cost of persistent connections is not justified
Realtime infrastructure should improve an actual user experience, not exist because “modern apps use WebSockets.”
Production checklist
Before launching live notifications, verify:
- every notification has a durable ID
- business-event creation is idempotent
- user preferences are centralized
- push permission is requested with context
- subscriptions/tokens are protected and cleaned up
- realtime transport is not the only history source
- offline clients can recover missed notifications
- unread/read state is server-authoritative
- multi-device duplicate delivery is tolerable
- retries distinguish transient and permanent failures
- slow clients have bounded queues
- authorization can be revoked on long-lived connections
- notification storms have rate/aggregation controls
A reliable notification system treats realtime delivery as an optimization over durable user state, not as the durable state itself.
References
BUILDING A SAAS PRODUCT?
Explore Lofingo’s end-to-end SaaS platforms, multi-tenant cloud architecture, and high-concurrency backend engineering.
EXPLORE SAAS SERVICES →