An unread notification counter in an SPA may work perfectly while the tab is active and suddenly show an old value after the user returns to the browser.
The cause is often not the counter itself or Laravel Echo. The real issue is the browser tab lifecycle: the tab may sleep, freeze, restore from cache, or remain in the background for a long time.
While the frontend is inactive, server state keeps changing. When the user returns, the local store may no longer match reality.
The Local Counter Is Only a Copy of Server State
Serverunread_count = 5 | | initial HTTP response v Vue storeunreadCount = 5The frontend store is not the source of truth for unread notifications. It is a convenient local projection of server state.
As long as every change reaches the browser, the copy remains correct. If even one update is missed, the counter becomes stale.
While the Tab Is Active, Echo Keeps Local State Current
Echo.private( `notification.user.${userId}`,).listen( '.notification.created', () => { notificationStore.refreshUnread(); },);While the tab is active, a WebSocket event is a convenient trigger. It tells the frontend that server state changed.
I do not necessarily increment the counter locally with +1. A safer option is to treat the event as a signal and fetch the current unread state from the server.
A Background Tab Does Not Guarantee an Uninterrupted Realtime Session
Tab active server = 2store = 2 Tab goes to background server = 3server = 4 connection sleeps / events missed User returns The browser may throttle background work, the network may disappear briefly, a laptop may sleep, or a mobile browser may freeze the page entirely.
That means "we are subscribed to a WebSocket" does not imply "we definitely observed every event since the page was opened."
visibilitychange Gives a Simple Reconciliation Point
const refreshWhenVisible = () => { if ( document.visibilityState !== 'visible' ) { return; } notificationStore.refreshUnread();}; document.addEventListener( 'visibilitychange', refreshWhenVisible,);I do not try to guess how many realtime events may have been missed.
When the tab becomes visible again, it is simpler to fetch the current unread state and replace the local value.
This turns returning to the tab into a reconciliation point.
pageshow Helps When the Page Returns from Browser History Cache
const refreshAfterPageShow = () => { notificationStore.refreshUnread();}; window.addEventListener( 'pageshow', refreshAfterPageShow,);The browser Back/Forward Cache may restore a page almost exactly as it was when the user left it.
This is useful for performance, but it means the normal initialization path may not be enough to guarantee fresh data.
pageshow provides another inexpensive point to reconcile state with the server.
I Use Multiple Triggers but One Refresh Method
const refreshUnread = async () => { await notificationStore.refreshUnread();}; const onVisibilityChange = () => { if ( document.visibilityState === 'visible' ) { void refreshUnread(); }}; const onPageShow = () => { void refreshUnread();};I do not want separate counter logic spread across the WebSocket handler, visibilitychange, pageshow, and initial load.
All of them merely say: "state may have changed." The store owns the operation that fetches the current value.
The Store Should Replace Unread State, Not Only Increment It
const refreshUnread = async () => { const response = await axios.get( '/notifications/unread-state', ); unreadCount.value = response.data.unread_count;};If the frontend only does unreadCount++, it depends on the complete history of delivered events.
A snapshot-based refresh depends only on current server state. That is why it recovers well after missed events.
The Read Action Should Update the Same Store
Otherwise the opposite drift appears: the notification is already read on the server while the local badge still shows the old number.
After marking a notification as read, I either use the API response containing the new unread count or run the same refreshUnread().
The important part is that every path converges to one authoritative state.
A Private Channel Signals Changes but Does Not Replace Authorization
Broadcast::channel( 'notification.user.{id}', function ( User $user, int $id, ): bool { return $user->id === $id; });A private channel is required so another user cannot receive someone else notification events.
But the HTTP endpoint for unread state still needs its own authorization boundary. The realtime channel and the API endpoint protect different entry points.
Multiple Refresh Triggers Should Not Create a Response Race
Request A starts |Request B starts |Request B returns newest state |Request A returns later with older snapshotpageshow, visibilitychange, and a WebSocket event may fire almost at the same time.
For a simple count endpoint this is often harmless, but if responses represent snapshots from different moments, an older response should not overwrite a newer one.
One simple option is to avoid starting another refresh while one is already in flight, or use a request sequence or version.
For a Simple Case, an In-Flight Guard Is Enough
let refreshPromise = null; const refreshUnread = () => { if (refreshPromise) { return refreshPromise; } refreshPromise = notificationStore .refreshUnread() .finally(() => { refreshPromise = null; }); return refreshPromise;};A Failed Refresh Should Not Reset the Counter to Zero
If the user returns to the tab without network access, the request may fail.
In that case I keep the last known state and let the next lifecycle or realtime trigger try reconciliation again.
A value of 0 is not a valid fallback when the server is merely unavailable.
The Notification State Ends Up with Multiple Recovery Paths
Initial page load | vHTTP unread snapshot Realtime event | vrefresh snapshot Tab becomes visible | vrefresh snapshot Page restoredTests Should Cover Returning to the Tab, Not Only Realtime Events
it( 'refreshes unread state when tab becomes visible', async () => { Object.defineProperty( document, 'visibilityState', { configurable: true, value: 'visible', }, ); document.dispatchEvent( new Event( 'visibilitychange', ),If there is only a test for the Echo event, we have proven only the active-tab scenario.
Separate tests for visibilitychange and pageshow protect the exact bug users see after returning to the SPA.
My Checklist for an Unread Notification Counter in an SPA
- Server state remains the source of truth.
- Initial load receives a current unread snapshot.
- The Echo private channel only signals that state changed.
- A realtime trigger can fetch the authoritative count.
visibilitychangerefreshes when the tab becomes visible.pageshowcovers browser history restoration.- Mark-as-read updates the same store.
- Concurrent refreshes cannot cause stale overwrite.
- A network failure does not reset the known value to zero.
- There is a test for returning to the tab.
Realtime Does Not Remove the Need for Reconciliation
The most useful lesson for me is simple: WebSockets are excellent for keeping live state current, but they do not guarantee an uninterrupted browser-session history.
An SPA needs a way to return to truth after sleep, a background tab, reconnect, or browser cache restoration.
Echo provides a fast signal, while visibilitychange and pageshow trigger reconciliation with the server. Together they make the unread counter not only realtime, but recoverable.
A WebSocket helps us keep up. Reconciliation helps us recover when we did not.



