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

В приложении с комнатой, где люди видят друг друга в реальном времени, сервер получает одно и то же событие «соединение закрылось», когда человек закрыл вкладку, обновил страницу, свернул браузер на телефоне или на секунду потерял 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 просто не появлялся.
Путь был такой.
Записывал, что делает закрывающаяся страница, в
localStorage. Первая версия записи сама ломала страницу. Вторая работала, но меняла тайминги и вместе с ними результат: инструмент диагностики искажал измеряемое.Сделал отправку маркера короче и первой по порядку в обработчике. Не помогло.
Отключил отладочный лог
stompjs. Не помогло.Измерил без записи: на локальной копии сервера маркер отправляли 8 закрытий из 24, на тестовом стенде 18 из 24.
Записи всё же показали главное: обработчик начинал работу и обрывался посреди отправки, без исключения и без ошибки в консоли. Следующие обработчики того же события при этом выполнялись.
Подсказка пришла случайно. Когда тест добавлял на
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. Итоговая схема: отошёл от стола и вернулся
Раз сервер не может отличить обновление от закрытия в момент закрытия, он и не пытается. Вместо этого он разделяет два понятия: «уйти из-за стола» и «уйти из комнаты».
Пришёл маркер закрытия, и соединение закрылось: человек сразу уходит из-за стола вместе с голосом, всем летит
PARTICIPANT_REMOVED. Остальные видят уход мгновенно.Но место 10 секунд помнит, кто ушёл и с каким голосом:
Departure(participant, vote).Обновлённая страница через секунду-две присылает
return, и сервер возвращает участника и голос в комнату, рассылаяPARTICIPANT_ADDEDиVOTE_ADDED.Никто не вернулся за 10 секунд: человек ушёл из комнаты окончательно, и только теперь пустая комната удаляется.



Цена этой схемы: при обновлении остальные на мгновение видят, как человек исчезает и возвращается, 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сдвигала тайминги. Измерять лучше снаружи, по логу сервера и по тому, что видят другие участники.
Итог: что происходит в каждом случае
Случай | Браузер | Сервер | Остальные видят |
|---|---|---|---|
Закрыл вкладку или браузер |
| сразу убирает из-за стола, 10с помнит место, потом удаляет из комнаты | уход через 0,1-0,5с |
Обновил страницу | через 1-2с | возвращает участника с голосом | мигание 0,4-1,9с, голос на месте |
Короткий обрыв сети | пульс не пропущен или переподключение за 1-5с + | место ждёт 10с | ничего |
Долгий обрыв | ничего | пропущенные пульсы (6-9с) + 10с ожидания | уход через 16-19с; вернувшийся видит форму с ником |
Браузер упал | ничего | закрытие без маркера = обрыв | уход примерно через 11с |
Страница ушла в bfcache |
| как обрыв | как обрыв |
Фоновая вкладка | пульс тикает в Worker | ничего | ничего |
Перезапуск сервера | переподключение + | 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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.