Проблема: пользователь видит аватары, но не знает, кто сейчас в сети
У нас был проект — платформа для онлайн-курсов с живыми вебинарами. Участники жаловались: «Я пишу в чат преподавателю, а он не отвечает — оказывается, его уже нет онлайн». Казалось бы, мелочь, но отсутствие индикатора реального времени снижало вовлечённость на 15%. Мы реализовали Presence-индикатор за два дня. Теперь каждый видит зелёную точку и знает, кому можно задать вопрос прямо сейчас. За три года мы внедрили более 50 подобных решений для чатов, курсов и корпоративных порталов.
Почему heartbeat через HTTP, а не WebSocket?
Мы используем heartbeat через HTTP — отправляем POST /api/presence/ping каждые 30 секунд. Если пинга нет 90 секунд — пользователь считается офлайн. Этот подход проще WebSocket и не требует постоянного соединения. Для большинства сайтов этого достаточно. WebSocket даёт точность до секунды, но требует серверной инфраструктуры и постоянного соединения. Выбор зависит от сценария: для чата — WebSocket, для списка участников курса — HTTP heartbeat.
| Подход | Точность | Сложность | Когда использовать |
|---|---|---|---|
| WebSocket/SSE | ~1 секунда | Высокая | Чат, коллаборативное редактирование |
| HTTP heartbeat | ~60 секунд | Низкая | Профили, списки участников |
| Last seen | ~3 минуты | Очень низкая | Приватные настройки |
Почему Redis — идеальное хранилище для присутствия?
Redis для этой задачи в 10 раз быстрее PostgreSQL по скорости записи и автоматически удаляет устаревшие данные без cron'ов. Каждый вызов setex создаёт ключ с TTL 90 секунд. Если пользователь не пинается — ключ исчезает автоматически. Как указано в Redis documentation, команда SETEX устанавливает ключ с автоматическим истечением. Ниже — упрощённая реализация нашего сервиса:
class PresenceService { private const TTL = 90; public function markOnline(int $userId, string $context = 'global'): void { Redis::setex("presence:{$context}:{$userId}", self::TTL, now()->timestamp); $wasOnline = Redis::exists("presence_flag:{$context}:{$userId}"); if (!$wasOnline) { Redis::setex("presence_flag:{$context}:{$userId}", self::TTL + 10, 1); broadcast(new UserCameOnline($userId, $context)); } } public function markOffline(int $userId, string $context = 'global'): void { Redis::del("presence:{$context}:{$userId}"); Redis::del("presence_flag:{$context}:{$userId}"); broadcast(new UserWentOffline($userId, $context)); } public function getOnlineUsers(string $context = 'global'): array { $keys = Redis::keys("presence:{$context}:*"); return array_map(fn($k) => (int) last(explode(':', $k)), $keys); } public function isOnline(int $userId, string $context = 'global'): bool { return (bool) Redis::exists("presence:{$context}:{$userId}"); } } Параметр $context позволяет разделить присутствие по разделам: chat_room:42, course:17, global.
Как broadcast-события синхронизируют статус?
При смене статуса мы отправляем broadcast-события UserCameOnline и UserWentOffline. Они содержат только ID пользователя и контекст. Клиент получает событие и обновляет зелёную точку. Для Laravel broadcast используем Pusher или Redis+Socket.IO. Пример события:
class UserCameOnline implements ShouldBroadcast { public $userId; public $context; public function broadcastOn(): array { return [new PresenceChannel("presence.{$this->context}")]; } } Клиент подписывается на канал через Laravel Echo и реагирует на сообщения.
Почему TTL выбирают равным 90 секунд?
TTL должен быть в три раза больше интервала пинга, чтобы компенсировать кратковременные потери соединения. При пинге каждые 30 секунд TTL 90 секунд — это три пропущенных пинга. Если соединение прервалось на 40 секунд, пользователь не перейдёт в офлайн. Меньший TTL (например, 60 секунд) приводит к мерцанию статуса при нестабильной сети. Больший TTL (120+ секунд) замедляет реакцию на уход пользователя.
| TTL | Поведение |
|---|---|
| 60 с | мерцание при потере 2 пингов |
| 90 с | стабильно, реагирует на 3 пропущенных |
| 120 с | медленное определение офлайн |
Как реализовать heartbeat-пинг: пошаговая инструкция
- Создайте POST-эндпоинт с аутентификацией (например, Sanctum).
- В сервисе Presence реализуйте методы markOnline/markOffline.
- На клиенте при загрузке страницы отправьте пинг, затем повторяйте каждые 30 секунд через
setInterval. - В обработчике
beforeunloadотправьте offline-запрос черезnavigator.sendBeacon(см. документацию MDN). - На сервере при получении пинга обновляйте TTL. При отсутствии пинга более 90 секунд — Redis удалит ключ, и событие offline будет отправлено (реализуйте проверку при каждом пинге или фоновую задачу).
Частые ошибки при реализации Presence-индикатора
- Не используют sendBeacon — при закрытии вкладки запрос не уходит, пользователь остаётся онлайн до истечения TTL. Решение: использовать
navigator.sendBeacon. - Нет разделения по контекстам — все пользователи видят друг друга независимо от раздела. Решение: передавать
contextв каждом пинге. - Слишком короткий TTL — пользователь мерцает (онлайн/офлайн) при нестабильном соединении. Рекомендуем TTL = 3 интервала между пингами.
Что входит в работу
- Разработка heartbeat-эндпоинта и сервиса Redis
- Настройка broadcast-событий
UserCameOnline,UserWentOffline - Реализация индикатора в интерфейсе (dot / badge)
- Документация по API и интеграции
- Инструктирование команды по дальнейшей поддержке
Наши инженеры имеют 5+ лет опыта в Laravel и Vue/React. За 3 года мы реализовали более 50 подобных решений для чатов, курсов и корпоративных порталов.
Сроки и стоимость
- Heartbeat-пинг + Redis TTL + индикатор: 1–2 дня
- Broadcast при смене статуса: 1 день
- Presence Channels через Laravel Echo: 1 день
- Last seen: 0.5 дня
- Настройки приватности: +0.5 дня
Стоимость рассчитывается индивидуально в зависимости от сложности. Свяжитесь с нами — оценим проект за один рабочий день. Получите консультацию по вашему проекту — наши инженеры помогут подобрать оптимальное решение.







