0% прочитано

Why Notification Counters Go Stale After Returning to a Browser Tab

In an SPA, an unread notification counter may stay correct while the tab is active and become stale after sleep, a background tab, or browser cache restoration. WebSockets are useful for live updates, but returning to the tab should reconcile state with the server.

21 жовтня 2026 р. 10 хв читанняVue.js

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.

Realtime updates and state recovery are different problems. Echo helps keep up during an active session, while visibilitychange and pageshow provide a point to reconcile after returning.

The Local Counter Is Only a Copy of Server State

At startup both states match
Serverunread_count = 5         |        | initial HTTP response        v Vue storeunreadCount = 5

The 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

A realtime event refreshes unread state
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

Stale state appears even when the counter logic itself is correct
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

Returning to visible triggers a refresh
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

pageshow covers restoration of an existing page
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

Lifecycle events only trigger one reconciliation operation
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

The server returns an authoritative snapshot
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

Channel authorization protects the per-user subscription
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

Even refresh logic can have a last-write problem
Request A starts    |Request B starts    |Request B returns newest state    |Request A returns later with older snapshot

pageshow, 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

Concurrent triggers share one network request
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

Every path converges on server state
Initial page load      |      vHTTP unread snapshot Realtime event      |      vrefresh snapshot Tab becomes visible      |      vrefresh snapshot Page restored

Tests Should Cover Returning to the Tab, Not Only Realtime Events

Lifecycle recovery deserves its own test
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.
  • visibilitychange refreshes when the tab becomes visible.
  • pageshow covers 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.