Call
APIs

Live Notifications Architecture: In-App Delivery, Push, Offline Recovery, and Read State

Design reliable live notifications across in-app realtime delivery and push channels with durable notification state, user preferences, deduplication, offline recovery, retries, unread counts, read receipts, and safe multi-device fan-out.

Published: October 21, 2025
Updated: September 12, 2026
11 min read
Live Notifications Architecture: In-App Delivery, Push, Offline Recovery, and Read State

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:

  1. HTTPS
  2. a service worker
  3. explicit user permission
  4. a push subscription
  5. server-side storage of the subscription endpoint and keys
  6. 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:

  1. Business service commits its state.
  2. A durable event/outbox records the notification-worthy change.
  3. Notification service creates one idempotent user notification.
  4. Preferences determine eligible channels.
  5. Active sessions receive realtime events through WebSocket or SSE.
  6. Background/offline devices receive Web Push or mobile push.
  7. Delivery attempts are logged independently from the notification.
  8. Client reconnects fetch missed notifications from durable history.
  9. User read actions update durable read_at state.
  10. 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

Tags:NotificationsReal-TimeWeb PushWebSocketsSSESystem DesignAPIs
ENTERPRISE SAAS ENGINEERING

BUILDING A SAAS PRODUCT?

Explore Lofingo’s end-to-end SaaS platforms, multi-tenant cloud architecture, and high-concurrency backend engineering.

EXPLORE SAAS SERVICES →