Лічильник unread notifications у SPA може працювати ідеально, поки вкладка активна, і раптом показати старе число після повернення до браузера.
Причина часто не в самому counter і не в Laravel Echo. Проблема в тому, що браузерна вкладка має власний життєвий цикл: її можуть приспати, заморозити, відновити з cache або залишити у background на довгий час.
Поки frontend неактивний, серверний state продовжує змінюватися. Коли користувач повертається, локальний store може вже не відповідати реальності.
Локальний counter є лише копією серверного стану
Serverunread_count = 5 | | initial HTTP response v Vue storeunreadCount = 5Frontend store не є джерелом істини для unread notifications. Це зручна локальна проєкція серверного state.
Поки всі зміни доходять до браузера, ця копія правильна. Але якщо хоча б одна зміна не була отримана, counter стає stale.
Поки вкладка активна, Echo добре підтримує локальний state
Echo.private( `notification.user.${userId}`,).listen( '.notification.created', () => { notificationStore.refreshUnread(); },);Коли вкладка активна, WebSocket-подія є дуже зручним trigger. Вона повідомляє frontend, що серверний state змінився.
Я при цьому не обов’язково збільшую counter локально на +1. Надійніший варіант - використати event як сигнал і перечитати актуальний unread state із сервера.
Background tab не гарантує безперервну realtime-сесію
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 дає просту точку повторної звірки
const refreshWhenVisible = () => { if ( document.visibilityState !== 'visible' ) { return; } notificationStore.refreshUnread();}; document.addEventListener( 'visibilitychange', refreshWhenVisible,);Я не намагаюся вгадати, скільки realtime events могло бути пропущено.
Коли вкладка знову стає visible, простіше перечитати поточний unread state і замінити локальне значення.
Це перетворює повернення у вкладку на reconciliation point.
pageshow потрібен для повернення через browser history cache
const refreshAfterPageShow = () => { notificationStore.refreshUnread();}; window.addEventListener( 'pageshow', refreshAfterPageShow,);Browser Back/Forward Cache може відновити сторінку майже в тому самому стані, в якому вона була залишена.
Це корисно для швидкості, але означає, що звичайний initialization path може не бути достатньою гарантією свіжих даних.
pageshow дає ще одну точку, де можна дешево звірити state із сервером.
Я використовую кілька trigger, але один refresh method
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, а не лише інкрементувати його
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
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 не повинні створювати гонку відповідей
Request A starts |Request B starts |Request B returns newest state |Request A returns later with older snapshotpageshow, visibilitychange і WebSocket event можуть спрацювати майже одночасно.
Для простого count endpoint це часто не критично, але якщо response містить snapshot із різним моментом часу, старіша відповідь не повинна перезаписати новішу.
Один із простих варіантів - не запускати другий refresh, поки перший ще виконується, або використовувати request sequence/version.
Для простого випадку достатньо in-flight guard
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 має кілька шляхів відновлення
Initial page load | vHTTP unread snapshot Realtime event | vrefresh snapshot Tab becomes visible | vrefresh snapshot Page restoredТести мають перевіряти не тільки realtime event, а й повернення у вкладку
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.
visibilitychangerefresh виконується при поверненні у 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 допомагає відновитися, якщо ми все ж відстали.



