0% прочитано

Чому лічильник нотифікацій застаріває після повернення у вкладку

У SPA лічильник unread notifications може бути правильним під час активної роботи й застаріти після sleep, background tab або повернення через browser cache. WebSocket добре доставляє зміни в реальному часі, але після повернення у вкладку state треба звірити з сервером.

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

Лічильник unread notifications у SPA може працювати ідеально, поки вкладка активна, і раптом показати старе число після повернення до браузера.

Причина часто не в самому counter і не в Laravel Echo. Проблема в тому, що браузерна вкладка має власний життєвий цикл: її можуть приспати, заморозити, відновити з cache або залишити у background на довгий час.

Поки frontend неактивний, серверний state продовжує змінюватися. Коли користувач повертається, локальний store може вже не відповідати реальності.

Realtime update і state recovery - це різні задачі. Echo допомагає не відставати під час активної сесії, а visibilitychange і pageshow дають точку для повторної звірки після повернення.

Локальний counter є лише копією серверного стану

На старті обидва стани збігаються
Serverunread_count = 5         |        | initial HTTP response        v Vue storeunreadCount = 5

Frontend store не є джерелом істини для unread notifications. Це зручна локальна проєкція серверного state.

Поки всі зміни доходять до браузера, ця копія правильна. Але якщо хоча б одна зміна не була отримана, counter стає stale.

Поки вкладка активна, Echo добре підтримує локальний state

Realtime event запускає оновлення unread state
Echo.private(    `notification.user.${userId}`,).listen(    '.notification.created',    () => {        notificationStore.refreshUnread();    },);

Коли вкладка активна, WebSocket-подія є дуже зручним trigger. Вона повідомляє frontend, що серверний state змінився.

Я при цьому не обов’язково збільшую counter локально на +1. Надійніший варіант - використати event як сигнал і перечитати актуальний unread state із сервера.

Background tab не гарантує безперервну realtime-сесію

Stale state з’являється без помилки в самому counter
Tab active server = 2store  = 2  Tab goes to background server = 3server = 4 connection sleeps / events missed  User returns 

Браузер може throttling background work, network може коротко зникнути, ноутбук може заснути, а mobile browser може взагалі заморозити сторінку.

Тому "ми підписані на WebSocket" не означає "ми гарантовано побачили всі події від моменту відкриття сторінки".

visibilitychange дає просту точку повторної звірки

Повернення вкладки у visible запускає refresh
const refreshWhenVisible = () => {    if (        document.visibilityState        !== 'visible'    ) {        return;    }     notificationStore.refreshUnread();}; document.addEventListener(    'visibilitychange',    refreshWhenVisible,);

Я не намагаюся вгадати, скільки realtime events могло бути пропущено.

Коли вкладка знову стає visible, простіше перечитати поточний unread state і замінити локальне значення.

Це перетворює повернення у вкладку на reconciliation point.

pageshow потрібен для повернення через browser history cache

pageshow покриває відновлення вже існуючої сторінки
const refreshAfterPageShow = () => {    notificationStore.refreshUnread();}; window.addEventListener(    'pageshow',    refreshAfterPageShow,);

Browser Back/Forward Cache може відновити сторінку майже в тому самому стані, в якому вона була залишена.

Це корисно для швидкості, але означає, що звичайний initialization path може не бути достатньою гарантією свіжих даних.

pageshow дає ще одну точку, де можна дешево звірити state із сервером.

Я використовую кілька trigger, але один refresh method

Lifecycle events лише запускають одну операцію звірки
const refreshUnread = async () => {    await notificationStore.refreshUnread();}; const onVisibilityChange = () => {    if (        document.visibilityState        === 'visible'    ) {        void refreshUnread();    }}; const onPageShow = () => {    void refreshUnread();};

Мені важливо не розносити окрему логіку counter по WebSocket handler, visibilitychange, pageshow і initial load.

Усі вони лише повідомляють: "можливо, state змінився". Саме store знає, як отримати актуальне значення.

Store повинен замінювати unread state, а не лише інкрементувати його

Сервер повертає authoritative snapshot
const refreshUnread = async () => {    const response =        await axios.get(            '/notifications/unread-state',        );     unreadCount.value =        response.data.unread_count;};

Якщо frontend робить тільки unreadCount++, він залежить від повної історії доставлених events.

Snapshot-based refresh залежить лише від поточного server state. Саме тому він добре відновлює систему після пропущених подій.

Read action теж має оновлювати той самий store

Інакше можна отримати протилежний drift: notification уже прочитана на сервері, але локальний badge все ще показує старе число.

Після mark-as-read я або використовую відповідь API з новим unread count, або запускаю той самий refreshUnread().

Для мене головне, щоб усі шляхи converged до одного authoritative state.

Private channel повідомляє про зміни, але не замінює authorization

Channel authorization захищає підписку конкретного користувача
Broadcast::channel(    'notification.user.{id}',    function (        User $user,        int $id,    ): bool {        return $user->id === $id;    });

Private channel потрібен, щоб чужі notification events не потрапили до іншого користувача.

Але HTTP endpoint для unread state все одно має власну authorization boundary. Realtime channel і API endpoint захищають різні entry points.

Кілька refresh triggers не повинні створювати гонку відповідей

Навіть refresh може мати last-write problem
Request A starts    |Request B starts    |Request B returns newest state    |Request A returns later with older snapshot

pageshow, visibilitychange і WebSocket event можуть спрацювати майже одночасно.

Для простого count endpoint це часто не критично, але якщо response містить snapshot із різним моментом часу, старіша відповідь не повинна перезаписати новішу.

Один із простих варіантів - не запускати другий refresh, поки перший ще виконується, або використовувати request sequence/version.

Для простого випадку достатньо in-flight guard

Одночасні triggers ділять один network request
let refreshPromise = null; const refreshUnread = () => {    if (refreshPromise) {        return refreshPromise;    }     refreshPromise =        notificationStore            .refreshUnread()            .finally(() => {                refreshPromise = null;            });     return refreshPromise;};

Невдалий refresh не повинен скидати counter у нуль

Якщо користувач повернувся у вкладку без мережі, request може впасти.

У такому випадку я залишаю last known state і дозволяю наступному lifecycle або realtime trigger повторити reconciliation.

Значення 0 не є коректним fallback, якщо сервер просто недоступний.

У результаті notification state має кілька шляхів відновлення

Усі paths сходяться до server state
Initial page load      |      vHTTP unread snapshot Realtime event      |      vrefresh snapshot Tab becomes visible      |      vrefresh snapshot Page restored

Тести мають перевіряти не тільки realtime event, а й повернення у вкладку

Lifecycle recovery варто захищати окремим тестом
it(    'refreshes unread state when tab becomes visible',    async () => {        Object.defineProperty(            document,            'visibilityState',            {                configurable: true,                value: 'visible',            },        );         document.dispatchEvent(            new Event(                'visibilitychange',            ),

Якщо є тест тільки на Echo event, ми довели лише active-tab сценарій.

Окремі tests для visibilitychange і pageshow захищають саме той bug, який користувач бачить після повернення до SPA.

Моя перевірка для unread notification counter у SPA

  • Server state залишається джерелом істини.
  • Initial load отримує актуальний unread snapshot.
  • Echo private channel лише сигналізує про зміну.
  • Realtime trigger може перечитати authoritative count.
  • visibilitychange refresh виконується при поверненні у visible.
  • pageshow покриває browser history restoration.
  • Mark-as-read оновлює той самий store.
  • Concurrent refreshes не створюють stale overwrite.
  • Network failure не скидає відоме значення в нуль.
  • Є тест на повернення у вкладку.

Realtime не скасовує потребу у reconciliation

Найкорисніший висновок для мене тут простий: WebSocket добре підтримує живий state, але не гарантує безперервну історію браузерної сесії.

SPA повинна мати спосіб повернутися до істини після sleep, background tab, reconnect або browser cache restoration.

Тому Echo дає швидкий сигнал, а visibilitychange і pageshow запускають повторну звірку з сервером. Разом вони роблять unread counter не тільки realtime, а й відновлюваним.

WebSocket допомагає не відставати. Reconciliation допомагає відновитися, якщо ми все ж відстали.