RTP DesportoPortugal gere vantagem confortável com segundo play-off para Mundial feminino na miraInquirer EntertainmentRJ Mitte, ‘Breaking Bad’ actor and disability rights champion, dies at 34Bollywood HungamaRahul Vaidya passes beeped remark about Disha Parmar on Pati Patni Aur Panga 2; leaves contestants shocked as fans call him out on RedditPunchUS roller coaster ‘incident’ kills one, injures severalThe Jerusalem PostNorth Korea's Kim showcases missile capable of reaching US as Trump signals possible meetingESPN DeportesAsí vivimos Guardians vs. Rays, J1 Serie de CampeonatoInquirerVP impeachment trial: Court releases November scheduleZDF heuteEntdecken Sie das ZDF-NachrichtenstudioCNN TürkM5 METRO KAZA MI YAPTI, PATLAMA MI OLDU, NEDEN GELMİYOR? 13 Ekim M5 Üsküdar-Sultanbeyli Metro Hattı Son DurumBillboardPitch Music & Arts Returns for 10th-Anniversary Edition in 2027سكاي نيوز عربيةالمتحدثة الجديدة باسم البيت الأبيض.. ماض مثير للجدل يطاردهاObservador DesportoÉ um erro o atual PSD tratar Passos como se fosse um inimigo
The Daily Newsstand · Free, Always
Tuesday, October 13, 2026

Закрыл вкладку — ушёл, обновил страницу — остался: присутствие в комнате на WebSocket

Translate

В приложении с комнатой, где люди видят друг друга в реальном времени, сервер получает одно и то же событие «соединение закрылось», когда человек закрыл вкладку, обновил страницу, свернул браузер на телефоне или на секунду потерял Wi-Fi. А вести себя в этих случаях надо по-разному. Рассказываю, как в своём Planning Poker я добился того, чтобы закрывший вкладку исчезал из-за стола за доли секунды, а обновивший страницу оставался вместе со своим голосом, через какие грабли прошёл в Spring, RabbitMQ, MongoDB, Firefox и zone.js и как это измерял.

Предыстория

Я решил сделать свой Planning Poker: сервис без регистрации, где ведущий создаёт комнату, присылает ссылку, участники входят по именам, каждый втайне выбирает карту, карты открываются, оценка принимается. Казалось, что самое сложное здесь закрытые карты и синхронное открытие. На деле самой упрямой оказалась другая задача: кто сейчас сидит за столом.

За столом сидят люди, и всем видно, кто за ним сидит. Если кто-то закрыл вкладку, а его место осталось занятым, карты не откроются, пока остальные не разберутся, чей это «призрак». А если обновил страницу и пропал вместе со своим голосом, это тоже раздражает. По дороге к ответу я собрал заметную коллекцию граблей в Spring, RabbitMQ, MongoDB, Firefox и zone.js, и именно ими хочу поделиться.

Задача

Стек обычный: Spring Boot на Java 25, STOMP поверх WebSocket (SockJS), RabbitMQ как STOMP-брокер с топиком на комнату, MongoDB; на клиенте Angular с NgRx и stompjs 2.3.3, это старая библиотека Джеффа Меснила 2014 года (пакет stompjs в npm), а не современный @stomp/stompjs; к этому я ещё вернусь в конце. Экземпляр сервера один, так что «кто сейчас онлайн» можно держать в памяти.

Требования, к которым пришёл после нескольких итераций:

Что сделал человек

Что должны увидеть остальные

Закрыл вкладку или весь браузер

Сразу: человек ушёл

Обновил страницу

Ничего: остался за столом вместе со своим голосом, в любом браузере

Пропал интернет на пару секунд

Ничего: остался, а сам после восстановления видит актуальный стол

Пропал интернет надолго

Ушёл. Когда связь вернётся, он увидит форму входа с подставленным ником и вернётся одним нажатием

Переключился на другую вкладку, свернул браузер на компьютере

Ничего: остался

Почему это сложно

Сервер во всех пяти случаях видит одно и то же: WebSocket закрылся. Закрытие вкладки, обновление страницы, падение браузера и обрыв сети на уровне соединения неразличимы.

Браузер знает чуть больше. Событие pagehide говорит «страница уходит намеренно», и это отделяет закрытие и обновление от обрыва сети. Но и браузер не отличает обновление от закрытия: pagehide срабатывает в обоих случаях, а то, что это было обновление, узнаёт только новая страница после загрузки, задним числом (performance.getEntriesByType('navigation')[0].type === 'reload').

Знание разделено. Браузер знает «ухожу намеренно» в момент ухода. Сервер узнаёт «он вернулся» через секунду-две после этого. Всё решение построено вокруг этого факта, но дошёл я до него не сразу.

Грабли 0. Сначала проверьте, что хранилище не теряет изменения

До всякого присутствия я прогнал живой тест: 10 комнат по 8 человек входят одновременно. Из 80 входов терялось 26-29. Голоса пропадали, вышедшие оставались в комнатах.

Причина классическая. Каждое действие делало «прочитать комнату целиком → изменить в памяти → сохранить целиком». Два параллельных действия в одной комнате, и последнее сохранение молча затирает первое. Коварство в том, что событие о первом действии к этому моменту уже разослано: на экранах всё выглядит нормально, а в базе человека нет.

Первой мыслью была оптимистическая блокировка через @Version. Отказался в пользу атомарных обновлений MongoDB: каждое изменение - одна операция над документом комнаты.

Две детали реализации в Spring Data

Атомарные операции живут в custom repository fragment: RoomRepository extends MongoRepository<Room, UUID>, AtomicRoomRepository, а реализация AtomicRoomRepositoryImpl подхватывается по суффиксу Impl; это настолько неочевидно, что заслуживает комментария в коде.

Ники сравниваются без учёта регистра и пробелов по краям, а MongoDB сравнивает строки точно, поэтому у участника есть отдельное поле key с нормализованным ником, и все условия в запросах идут по нему.

Присутствие - это поток конкурентных вставок и удалений одного и того же человека. Если хранилище не атомарно, никакая логика сверху не спасёт.

Грабли 1. Исходная версия: «ушёл» по beforeunload

В первой версии клиент на beforeunload отправлял сообщение «я выхожу». Проблемы видны сразу:

  • обновление страницы = выход: голос пропадает, а если человек был один, комната удаляется;

  • упавший браузер, убитый процесс, разряженный телефон, обрыв сети: сообщение не уходит, за столом навсегда остаётся “призрак”;

  • beforeunload ненадёжен на мобильных и отключает back-forward cache.

Шаг 1. Присутствие по соединению и период ожидания

Идея: человек держит своё место открытым STOMP-соединением, а сервер сам решает, когда место освободилось.

На сервере (RoomPresence) при входе в комнату место (Seat) запоминает id STOMP-сессии. На SessionDisconnectEvent, если место больше никто не держит, планируется выход через presence.grace-period (PresenceConfig), у меня 10 секунд.

На клиенте вкладка запоминает своё место в sessionStorage под ключом с id комнаты: ник и роль (seat-storage.ts). sessionStorage принадлежит вкладке и переживает обновление, но не закрытие, ровно то, что нужно. После обновления или переподключения клиент отправляет /app/room/{id}/participants/return. Если место ещё ждёт, запланированный выход отменяется, и остальные ничего не замечают. Если не дождалось, показывается форма входа с прежним ником. После переподключения комната загружается заново.

Отдельный случай - перезапуск сервера. Сессии в памяти пропали, а участники в MongoDB остались. Поэтому при старте каждому сохранённому участнику даётся presence.startup-grace-period = 30 секунд на возвращение, кто не вернулся - удаляется.

Итог шага: обновление и короткий обрыв работают, призраков нет. Но закрывший вкладку сидит за столом ещё 10 секунд, а требование говорит «сразу».

Грабли 2. Пульс и фоновые вкладки

У stompjs по умолчанию heartbeat 10 секунд. Сервер замечает пропажу соединения после двух-трёх пропущенных ударов, плюс 10 секунд ожидания. В живой проверке людей при этом иногда выкидывало и при обрывах на 4-8 секунд.

Пульс

Обрыв замечен через

Человек уходит через

10с (по умолчанию)

20-30с

30-40с вместо «примерно десяти»

3с в обе стороны

6-9с

16-19с, обрывы короче ~10с никого не выкидывают

Цена короткого пульса: браузер, свёрнутый на телефоне дольше 15 секунд, теряет место, потому что мобильные ОС замораживают вкладку. Возврат - одним нажатием. На это пошёл сознательно.

Дальше вылезли фоновые вкладки на компьютере. Браузеры замедляют таймеры неактивных вкладок, Chrome до раза в минуту. Пульс останавливается, сервер считает соединение мёртвым и выкидывает человека, который просто переключился на другую вкладку. Решение: таймеры пульса тикают в Web Worker (worker-heartbeat.ts), его таймеры не замедляются. stompjs берёт таймеры из window.Stomp.setInterval и clearInterval, их и подменяем.

Заодно подкрутил переподключение: паузы 1, 2, 4, 5 секунд (раньше первая была 5 секунд, и за это время место успевало уйти) и немедленная попытка по событию online. И починил зависший запрос: тот, что ждал ответа в момент обрыва, ждал его на мёртвом соединении вечно, из-за чего проверка ника зависала, а кнопка «Войти» не работала. Теперь запрос повторяется на новом соединении.

Шаг 2. Как отличить закрытие от обрыва связи

Рассматривал три варианта.

Коды закрытия WebSocket: 1000 и 1001 при штатном закрытии,1006 при обрыве. Отказался: stompjs сам штатно закрывает сокет, когда пропускает пульсы сервера, и этот «чистый» close-фрейм после короткого обрыва вполне может дойти до сервера. Сервер принял бы обрыв сети за намеренное закрытие.

navigator.sendBeacon на отдельный HTTP-эндпоинт. Изучал, но не стал пробовать: пришлось бы связывать HTTP-запрос со STOMP-сессией, и тот же вопрос порядка (что придёт раньше, beacon или закрытие сокета) никуда не делся. Вживую не проверял, так что это альтернатива, а не отвергнутый по измерениям вариант.

Явный маркер закрытия по уже открытому соединению. Выбрал её. На pagehide клиент отправляет STOMP-сообщение /app/presence/page-closed с пустым телом; в базе оно не хранится.

На сервере всё сводится к двум событиям, маркеру закрытия и закрытию соединения, и к тому, в каком порядке они приходят:

Что произошло

Что делает сервер

Пришел маркер закрытия, соединение пока открыто

Запоминает: эта сессия закрывается намеренно

Соединение закрылось, и маркер закрытия от него был

Выводит человека из-за стола сразу

Соединение закрылось, а маркера закрытия не было

Считает это обрывом связи и ждёт 10 секунд: вдруг вернётся

Два нюанса на клиенте (room-web-socket.service.ts). Событие именно pagehide, а не beforeunload или unload. И если event.persisted === true, страница уходит в back-forward cache и может вернуться, поэтому маркер закрытия не отправляем; если её соединение потом закроется, сервер отнесётся к этому как к обрыву.

// room-web-socket.service.ts (сокращено)
private sayPageClosed(event: PageTransitionEvent) {
  if (!event.persisted && !this.pageClosedSaid && this.stompClient !== null && this.connected$.value) {
    this.stompClient.send(RoomDestinations.pageClosed, {}, "");
    this.pageClosedSaid = true;
  }
}
@Controller
public class PresenceController {
    // A page that is being closed says so right before its connection closes
    @MessageMapping("/presence/page-closed")
    void pageClosed(@Header(SimpMessageHeaderAccessor.SESSION_ID_HEADER) String sessionId) {
        roomPresence.pageClosed(sessionId);
    }
}

Грабли 3. Порядок кадров: маркер закрытия приходит после закрытия соединения

Spring по умолчанию обрабатывает входящие кадры одного соединения в пуле потоков, то есть не обязательно по порядку.

Узнал я об этом в другом месте: UNSUBSCRIBE, отправленный сразу после SUBSCRIBE, обгонял его по дороге в RabbitMQ, RabbitMQ отвечал кадром ERROR, а Spring после любого ERROR закрывает соединение браузера. В Firefox из-за этого зависала проверка ника.

Шаг 3. Включить порядок обработки кадров

Лечится одной строкой в registerStompEndpoints (WebSocketConfig): registry.setPreserveReceiveOrder(true). У этого есть побочный эффект. Ошибки сервер шлёт в личную очередь /user/topic/room.errors, и раньше клиент подписывался на неё в момент запроса, впритык. Пока кадры обрабатывались вразнобой, это сходило с рук. Теперь кадры идут строго по порядку: если запрос ушёл раньше подписки на ошибки, ответ с ошибкой доставляется раньше, чем подписка успела появиться, и пропадает. Поэтому клиент подписывается на ошибки один раз, при открытии комнаты, и держит эту подписку, пока комната открыта, вместо того чтобы подписываться и отписываться на каждый запрос.

Но даже при сохранённом порядке SessionDisconnectEvent может обработаться раньше маркера закрытия, если предыдущий кадр обрабатывался долго. Поэтому место запоминает сессию, которая закрылась последней, и опоздавший маркер закрытия обрывает уже начавшееся десятисекундное ожидание: человек уходит сейчас.

public void pageClosed(String sessionId) {
    synchronized(lock) {
        if(sessionSeats.containsKey(sessionId)) {   // соединение ещё открыто
            closingSessions.add(sessionId);
            return;
        }
        // соединение уже закрыто и место ждёт, как при обрыве: уходим сейчас
        seats.forEach((seatKey, seat) -> {
            if(sessionId.equals(seat.closedSessionId) && seat.sessionIds.isEmpty() && !seat.leaving
                    && seat.departure == null) {
                scheduleSteppingAway(seatKey, seat);
            }
        });
    }
}

Грабли 4. Firefox теряет маркер закрытия примерно в половине закрытий

Самая длинная часть расследования. Симптом: в Chrome закрывший вкладку исчезал за 0,1 секунды, в Firefox часто сидел ещё 11 секунд. В логе сервера маркер закрытия из Firefox просто не появлялся.

Путь был такой.

  1. Записывал, что делает закрывающаяся страница, в localStorage. Первая версия записи сама ломала страницу. Вторая работала, но меняла тайминги и вместе с ними результат: инструмент диагностики искажал измеряемое.

  2. Сделал отправку маркера короче и первой по порядку в обработчике. Не помогло.

  3. Отключил отладочный лог stompjs. Не помогло.

  4. Измерил без записи: на локальной копии сервера маркер отправляли 8 закрытий из 24, на тестовом стенде 18 из 24.

  5. Записи всё же показали главное: обработчик начинал работу и обрывался посреди отправки, без исключения и без ошибки в консоли. Следующие обработчики того же события при этом выполнялись.

  6. Подсказка пришла случайно. Когда тест добавлял на pagehide ещё один, более ранний обработчик, обрывался чаще всего именно он, а мой доходил: 22 раза из 24.

Вывод, и это наблюдение по измерениям, а не спецификация и не найденный баг в Bugzilla: Firefox при закрытии или перезагрузке вкладки может прервать скрипт, который выполняется в этот момент, а следующие обработчики того же события продолжают выполняться.

Шаг 4. Маркер закрытия отправляют два обработчика

Первый отправляет. Второй отправляет, только если первый не успел: флаг pageClosedSaid выставляется сразу после send.

Подвох Angular: zone.js вызывает все обработчики одного события и одной фазы из одного нативного колбэка. Если Firefox прерывает этот колбэк, умирают оба обработчика сразу. Поэтому они висят на разных фазах: один на capture, другой на bubble.

window.addEventListener("pagehide", this.onPageHide, true); // capture: срабатывает первым
window.addEventListener("pagehide", this.onPageHide);       // bubble: страхует первый

Результат: 36 закрытий и обновлений из 36. Остальные видят уход через 0,04-0,17 секунды на локальной копии и через 0,4-0,5 секунды на стенде за Cloudflare.

Грабли 5. Обновление страницы и мгновенный выход

Как только выход стал мгновенным, обновление страницы тоже стало выходом, в Chrome всегда. В Firefox место при обновлении сохранялось, но только потому, что маркер закрытия терялся: баг маскировал баг. Ручную проверку вдобавок путал закэшированный index.html: после выкладки браузер показывал старую сборку до Ctrl+Shift+R. Это лечится Cache-Control: no-cache на index.html, а бандлы с хэшем в имени можно кэшировать навсегда.

Варианты, между которыми выбирал:

  • Ждать 3 секунды, потом убирать. Обновившаяся страница успевает вернуться, и остальные ничего не замечают, голос цел. Но закрывший вкладку сидит за столом ещё 3 секунды, а хотелось «сразу».

  • Убирать сразу. Закрытие мгновенное, но обновление страницы стирает голос, а если человек был за столом один, вместе с ним удаляется и комната.

  • Сразу показывать место потускневшим, а убирать через 3 секунды. Остальные видят, что человек, кажется, уходит, но место ещё держится; если страница вернулась, место снова загорается. Выглядит честно, но это третье состояние места с собственным событием и анимацией, заметно сложнее первых двух.

Выбрал «и то и другое»: обновление оставляет за столом во всех браузерах, закрытие выводит сразу. Дальше инженерная часть.

Шаг 5. Итоговая схема: отошёл от стола и вернулся

Раз сервер не может отличить обновление от закрытия в момент закрытия, он и не пытается. Вместо этого он разделяет два понятия: «уйти из-за стола» и «уйти из комнаты».

  1. Пришёл маркер закрытия, и соединение закрылось: человек сразу уходит из-за стола вместе с голосом, всем летит PARTICIPANT_REMOVED. Остальные видят уход мгновенно.

  2. Но место 10 секунд помнит, кто ушёл и с каким голосом: Departure(participant, vote).

  3. Обновлённая страница через секунду-две присылает return, и сервер возвращает участника и голос в комнату, рассылая PARTICIPANT_ADDED и VOTE_ADDED.

  4. Никто не вернулся за 10 секунд: человек ушёл из комнаты окончательно, и только теперь пустая комната удаляется.

Закрытие вкладки: маркер закрытия, соединение закрыто, уход из-за стола сразу, через 10 секунд из комнаты

Закрытие вкладки: маркер закрытия, соединение закрыто, уход из-за стола сразу, через 10 секунд из комнаты

Обновление страницы: уход из-за стола, через 1-2 секунды return, участник и голос возвращаются

Обновление страницы: уход из-за стола, через 1-2 секунды return, участник и голос возвращаются

Обрыв сети: короткий - место ждёт и дожидается, долгий - пропущенные пульсы и уход через 16-19 секунд

Обрыв сети: короткий - место ждёт и дожидается, долгий - пропущенные пульсы и уход через 16-19 секунд

Цена этой схемы: при обновлении остальные на мгновение видят, как человек исчезает и возвращается, 0,4-0,9 секунды на локальной копии и 1,5-1,9 секунды на стенде. Это осознанный выбор, а не баг: мигание принципиально неустранимо без задержки ухода при закрытии.

Мелочи, без которых схема ломается:

  • пока единственный участник «отошёл», пустая комната не удаляется;

  • если за эти 10 секунд начался новый раунд, запомненный голос выбрасывается, причём очистка запомненных голосов вызывается до очистки голосов в базе, иначе гонка;

  • вернувшаяся страница может увидеть себя дважды, в снимке комнаты и в событии о своём возвращении, поэтому редьюсер не добавляет участника, который уже сидит за столом;

  • если за 10 секунд ник занял кто-то другой, вернуть нельзя: показываем форму входа с прежним ником.

Грабли 6. Гонка на возврате: вернулся, но его никто не видит

Редкий сбой: после обновления человек сидит за столом, а остальные его не видят.

Удаление из-за стола - два шага: изменить MongoDB, потом разослать событие. Параллельный return того же ника мог успеть разослать своё событие раньше. Остальные получали ADDED, который игнорировался, потому что человек ещё за столом, затем REMOVED, и человек пропадал. Плюс return, пришедший, пока шёл уход из-за стола, отклонялся, и страница показывала форму входа. Планировщик присутствия по умолчанию однопоточный (ThreadPoolTaskScheduler), поэтому уход одного человека мог стоять в очереди за массовым уходом в нагрузочном тесте достаточно долго. Честно скажу: причина выведена из кода и нагрузочного теста, вживую поймана не была.

Шаг 6. Лок от изменения в БД до рассылки события

SeatLocks: 64 полосатых лока по хэшу пары (комната, ник). Каждое изменение места человека (вход, выход, уход из-за стола, возврат) держит его лок от изменения в базе до рассылки события, так что события о человеке уходят в том порядке, в каком менялось хранилище. Отсюда следствие: события рассылаются вручную внутри лока, а не через @SendTo после возврата из метода. return во время ухода ждёт его окончания и сразу возвращает человека. Набор локов фиксированный, их не надо удалять, когда человек уходит; два человека, попавшие в один лок, просто подождут друг друга мгновение. Правило одно: держа один лок, не брать другой. Пул планировщика стал 4 потока.

public <T> T change(UUID roomId, String nickname, Supplier<T> change) {
    synchronized(lockOf(roomId, nickname)) {
        return change.get();
    }
}

private Object lockOf(UUID roomId, String nickname) {
    int hash = Objects.hash(roomId, Participant.normalizeNickname(nickname));
    return locks[Math.floorMod(hash, LOCK_COUNT)];
}

Грабли 7. Как это вообще тестировать

Юнит-тестов здесь мало: всё решают тайминги настоящих браузеров. Поэтому сделал живые E2E на Playwright, которые гоняются из GitHub Actions по тестовому стенду и по локальной копии сервера, собранной из веток.

Сценарии: два раунда голосования командой; люди на iPhone и Pixel; закрытие вкладки; закрытие браузера; обновление страницы; обрыв сети на 4,5 секунды и надолго; упавший браузер; 10 команд по 8 человек. Закрытие проверяется в Chrome, Firefox и WebKit. Тест измеряет, через сколько секунд остальные видят уход или возврат, а не только сам факт. Сбои вероятностные, поэтому важные сценарии гоняются по 24-36 раз подряд и сравниваются доли.

Подвохи Playwright, которые стоили времени
  • headless shell Chromium при context.close() не вызывает pagehide, нужен полный Chromium (channel: 'chromium');

  • WebKit в Playwright закрывает контекст грубо, не давая странице попрощаться, поэтому тест закрытия браузера там пропущен;

  • обрыв сети через setOffline честно работает только в Chromium;

  • WebKit в Playwright не передаёт HTTP basic auth при WebSocket-рукопожатии: за логином стенда SockJS тихо переключался на XHR. Решил cookie-логином на стенде.

Главный урок диагностики: она меняет поведение. Запись в localStorage сдвигала тайминги. Измерять лучше снаружи, по логу сервера и по тому, что видят другие участники.

Итог: что происходит в каждом случае

Случай

Браузер

Сервер

Остальные видят

Закрыл вкладку или браузер

pagehide → маркер закрытия → соединение закрыто

сразу убирает из-за стола, 10с помнит место, потом удаляет из комнаты

уход через 0,1-0,5с

Обновил страницу

через 1-2с return

возвращает участника с голосом

мигание 0,4-1,9с, голос на месте

Короткий обрыв сети

пульс не пропущен или переподключение за 1-5с + return

место ждёт 10с

ничего

Долгий обрыв

ничего

пропущенные пульсы (6-9с) + 10с ожидания

уход через 16-19с; вернувшийся видит форму с ником

Браузер упал

ничего

закрытие без маркера = обрыв

уход примерно через 11с

Страница ушла в bfcache

persisted = true, маркера нет

как обрыв

как обрыв

Фоновая вкладка

пульс тикает в Worker

ничего

ничего

Перезапуск сервера

переподключение + return

30с на возвращение всем из базы

ничего

Ограничения

  • Места хранятся в памяти одного экземпляра сервера. Для нескольких экземпляров нужно общее хранилище присутствия, например Redis с TTL, и маршрутизация событий о человеке через него.

  • Свёрнутый на телефоне браузер теряет место через 16-19 секунд: мобильные ОС замораживают вкладку, и Worker не спасает.

  • Поведение Firefox с прерыванием скрипта - наблюдение по измерениям; документированного описания я не нашёл. Если вы знаете соответствующий баг или спецификацию, напишите в комментариях.

Что я узнал уже после

Когда схема работала, я обнаружил, что stompjs, которым пользовался, давно не развивается, а под именем @stomp/stompjs живёт другая библиотека (сейчас версия 7). Судя по её документации и списку изменений, часть того, что я делал руками, там есть из коробки: переподключение с экспоненциальной задержкой, хук перед каждым подключением, а с версии 7.1 и отправка пульса из Web Worker, то есть как раз обход замороженных таймеров в фоновой вкладке из граблей 2. Для Angular есть и обёртка @stomp/rx-stomp.

Я ей ещё не пользовался, поэтому не знаю, насколько хорошо всё это работает на практике и закрыла бы она остальные грабли: порядок кадров, pagehide в Firefox и гонку на возврате к ней отношения не имеют. Но если вы начинаете проект сейчас, смотреть стоит сначала на неё; если уже перешли, расскажите в комментариях, что удалось выкинуть.

Выводы

  • Сервер не может отличить закрытие от обновления. Не гадать: разделить на «ушёл из-за стола» и «ушёл из комнаты».

  • О намерении знает только браузер, и сообщить его может только маркером закрытия по уже открытому соединению на pagehide. Коды закрытия WebSocket ненадёжны.

  • Маркер закрытия может опоздать и потеряться. Сервер должен работать и без неё, по пульсу, и уметь принять его после закрытия соединения.

  • Порядок важен на каждом уровне: кадры одного соединения, изменение в базе и событие о нём, уход и возврат одного человека.

  • Пульс настраивается под требования и переносится в Worker.

  • Тестировать в настоящих браузерах, много раз, с измерением времени, и помнить, что диагностика меняет измеряемое.

Всё описанное работает на pipoker.app, там это можно проверить самому: открыть комнату в двух браузерах, одну закрыть, другую обновить.

Код открыт:

Полезное чтение: pagehide на MDN, bfcache, порядок сообщений STOMP в Spring.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.