Синхронизация между вкладками

При работе с WebSocket-соединениями через библиотеку STOMP.js часто возникает проблема множественных подключений. Если пользователь открывает несколько вкладок одного сайта, каждая вкладка создает собственное STOMP-подключение к брокеру сообщений.

Подобное поведение приводит к ряду последствий:

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

Синхронизация между вкладками позволяет:

  • использовать одно активное соединение;
  • распределять сообщения между вкладками;
  • передавать события без повторных запросов;
  • синхронизировать состояние интерфейса;
  • координировать авторизацию;
  • реализовывать leader election;
  • снижать нагрузку на backend.

Основные подходы к синхронизации

Для координации вкладок браузера обычно используются:

Технология Назначение
BroadcastChannel Передача сообщений между вкладками
localStorage events События изменения localStorage
SharedWorker Общий воркер для всех вкладок
Service Worker Центральная точка коммуникации
IndexedDB Хранение общего состояния
Web Locks API Блокировки и выбор лидера

Наиболее популярным вариантом считается комбинация:

  • STOMP.js
  • BroadcastChannel
  • leader election

Проблема множественных WebSocket-подключений

Типичная реализация выглядит так:

import { Client } from '@stomp/stompjs';

const client = new Client({
    brokerURL: 'ws://localhost:15674/ws'
});

client.activate();

Если открыто пять вкладок:

  • создаются пять WebSocket-соединений;
  • выполняются пять подписок;
  • брокер отправляет сообщения пять раз;
  • обработчики выполняются независимо.

При высокой нагрузке это становится критично.


Архитектура single connection

Схема работы:

  1. Одна вкладка становится лидером.
  2. Лидер открывает STOMP-соединение.
  3. Остальные вкладки работают как клиенты.
  4. Сообщения распространяются через BroadcastChannel.
  5. При закрытии лидера выбирается новый.

Использование BroadcastChannel

Создание канала

const channel = new BroadcastChannel('stomp-sync');

Все вкладки с одинаковым именем канала могут обмениваться сообщениями.


Отправка сообщений

channel.postMessage({
    type: 'CHAT_MESSAGE',
    payload: {
        text: 'Hello'
    }
});

Получение сообщений

channel.onmess age = (event) => {
    console.log(event.data);
};

Выбор лидирующей вкладки

Простая схема election

Каждая вкладка получает уникальный идентификатор:

const TAB_ID = crypto.randomUUID();

Попытка занять роль лидера:

localStorage.setItem('stomp-leader', TAB_ID);

Проверка:

const currentLeader = localStorage.getItem('stomp-leader');

const isLeader = currentLeader === TAB_ID;

Но подобная реализация небезопасна из-за гонок.


Leader election через heartbeat

Более надежный вариант использует heartbeat.

Отправка heartbeat

Лидер периодически обновляет timestamp:

setInterval(() => {
    localStorage.setItem('stomp-heartbeat', Date.now());
}, 1000);

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

function isLeaderAlive() {
    const heartbeat = Number(
        localStorage.getItem('stomp-heartbeat')
    );

    return Date.now() - heartbeat < 3000;
}

Захват лидерства

function tryBecomeLeader() {
    if (!isLeaderAlive()) {
        localStorage.setItem('stomp-leader', TAB_ID);
    }
}

Создание единственного STOMP-подключения

Лидер создает клиента:

import { Client } from '@stomp/stompjs';

const client = new Client({
    brokerURL: 'ws://localhost:15674/ws',
    reconnectDelay: 5000
});

Активация подключения

client.onConn ect = () => {
    console.log('Connected');
};

client.activate();

Передача сообщений между вкладками

Получение STOMP-сообщения лидером

client.subscribe('/topic/messages', (message) => {
    channel.postMessage({
        type: 'STOMP_EVENT',
        payload: JSON.parse(message.body)
    });
});

Получение сообщения клиентскими вкладками

channel.onmess age = (event) => {
    if (event.data.type === 'STOMP_EVENT') {
        renderMessage(event.data.payload);
    }
};

Теперь только одна вкладка использует WebSocket.


Синхронизация отправки сообщений

Проблема publish

Только лидер имеет STOMP-подключение.

Остальные вкладки должны передавать команды лидеру.


Отправка команды через BroadcastChannel

function sendChatMessage(text) {
    channel.postMessage({
        type: 'SEND_MESSAGE',
        payload: {
            text
        }
    });
}

Лидер публикует сообщение

channel.onmess age = (event) => {
    if (event.data.type === 'SEND_MESSAGE') {
        client.publish({
            destination: '/app/chat',
            body: JSON.stringify(event.data.payload)
        });
    }
};

Централизованное хранение состояния

Часто используется единый store состояния.

Например:

const state = {
    connected: false,
    messages: [],
    users: []
};

Синхронизация состояния

Лидер обновляет store:

function updateState(data) {
    state.messages.push(data);

    channel.postMessage({
        type: 'STATE_UPDATE',
        payload: state
    });
}

Получение обновлений

channel.onmess age = (event) => {
    if (event.data.type === 'STATE_UPDATE') {
        hydrate(event.data.payload);
    }
};

Синхронизация авторизации

Проблема access token

Если токен обновляется в одной вкладке, остальные могут использовать устаревшее значение.


Передача нового токена

channel.postMessage({
    type: 'TOKEN_UPDATED',
    payload: {
        accessToken
    }
});

Обновление токена

channel.onmess age = (event) => {
    if (event.data.type === 'TOKEN_UPDATED') {
        authStore.setToken(
            event.data.payload.accessToken
        );
    }
};

Автоматическая передача reconnect-состояния

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

client.onConn ect = () => {
    channel.postMessage({
        type: 'CONNECTED'
    });
};

client.onDisconn ect = () => {
    channel.postMessage({
        type: 'DISCONNECTED'
    });
};

Реакция вкладок

channel.onmess age = (event) => {
    switch (event.data.type) {
        case 'CONNECTED':
            showOnline();
            break;

        case 'DISCONNECTED':
            showOffline();
            break;
    }
};

Синхронизация подписок

Централизованная модель

Только лидер управляет подписками.

const subscriptions = new Map();

Регистрация подписки

channel.postMessage({
    type: 'SUBSCRIBE',
    payload: {
        destination: '/topic/orders'
    }
});

Лидер создает подписку

channel.onmess age = (event) => {
    if (event.data.type === 'SUBSCRIBE') {
        const destination =
            event.data.payload.destination;

        if (!subscriptions.has(destination)) {
            const sub = client.subscribe(
                destination,
                handleMessage
            );

            subscriptions.set(destination, sub);
        }
    }
};

Очистка подписок

Отписка

channel.postMessage({
    type: 'UNSUBSCRIBE',
    payload: {
        destination: '/topic/orders'
    }
});

Удаление подписки лидером

channel.onmess age = (event) => {
    if (event.data.type === 'UNSUBSCRIBE') {
        const sub = subscriptions.get(
            event.data.payload.destination
        );

        if (sub) {
            sub.unsubscribe();

            subscriptions.delete(
                event.data.payload.destination
            );
        }
    }
};

Использование localStorage events

Альтернатива BroadcastChannel

window.addEventListener('storage', (event) => {
    if (event.key === 'stomp-message') {
        const data = JSON.parse(event.newValue);

        console.log(data);
    }
});

Отправка данных

localStorage.setItem(
    'stomp-message',
    JSON.stringify({
        text: 'hello'
    })
);

Ограничения localStorage events

Недостатки подхода:

  • ограниченный размер данных;
  • синхронный API;
  • низкая производительность;
  • необходимость сериализации;
  • отсутствие очередей;
  • возможны пропуски событий.

BroadcastChannel значительно удобнее.


SharedWorker как единый транспорт

Идея SharedWorker

SharedWorker позволяет создать единый JavaScript-контекст для всех вкладок.

Архитектура:

Tabs
  ↓
SharedWorker
  ↓
STOMP.js
  ↓
Broker

SharedWorker

const connections = [];

onconn ect = (event) => {
    const port = event.ports[0];

    connections.push(port);

    port.start();
};

Подключение из вкладки

const worker = new SharedWorker(
    '/stomp-worker.js'
);

worker.port.start();

STOMP.js внутри SharedWorker

Инициализация клиента

importScripts('/stomp.umd.js');

const client = new StompJs.Client({
    brokerURL: 'ws://localhost:15674/ws'
});

client.activate();

Рассылка сообщений вкладкам

client.subscribe('/topic/chat', (message) => {
    connections.forEach((port) => {
        port.postMessage(message.body);
    });
});

Преимущества SharedWorker

Преимущество Описание
Одно соединение Только один WebSocket
Общая память Централизованное состояние
Нет leader election Worker всегда единый
Производительность Меньше нагрузки
Удобство Простая архитектура

Недостатки SharedWorker

Недостаток Описание
Поддержка браузеров Ограниченная совместимость
Сложность отладки Worker-debugging
HTTPS Требуется secure context
CSP Возможны ограничения

Service Worker и STOMP.js

Service Worker плохо подходит для постоянных WebSocket-подключений.

Причины:

  • worker может быть выгружен браузером;
  • отсутствует гарантированное время жизни;
  • reconnect становится нестабильным;
  • WebSocket не предназначен для background lifecycle.

Поэтому SharedWorker предпочтительнее.


Web Locks API

Управление лидерством

Web Locks API позволяет безопасно выбирать лидера.

navigator.locks.request(
    'stomp-leader-lock',
    async (lock) => {
        startLeaderMode();
    }
);

Преимущества Web Locks API

  • отсутствие гонок;
  • автоматическое освобождение;
  • безопасный election;
  • современный API;
  • меньше кода.

Обработка закрытия вкладки

Передача роли лидера

window.addEventListener('beforeunload', () => {
    if (isLeader) {
        channel.postMessage({
            type: 'LEADER_LEFT'
        });
    }
});

Выбор нового лидера

channel.onmess age = (event) => {
    if (event.data.type === 'LEADER_LEFT') {
        tryBecomeLeader();
    }
};

Синхронизация очередей сообщений

Буферизация

Во время reconnect сообщения могут временно храниться:

const pendingMessages = [];

Добавление в очередь

function enqueue(message) {
    pendingMessages.push(message);
}

Повторная отправка

client.onConn ect = () => {
    while (pendingMessages.length) {
        const msg = pendingMessages.shift();

        client.publish(msg);
    }
};

Синхронизация offline-режима

Глобальный offline-state

channel.postMessage({
    type: 'OFFLINE_MODE'
});

Переключение интерфейса

channel.onmess age = (event) => {
    if (event.data.type === 'OFFLINE_MODE') {
        enableOfflineUI();
    }
};

Дедупликация сообщений

Проблема повторов

Во время reconnect могут приходить дубликаты сообщений.


Использование message-id

const processed = new Set();

Проверка уникальности

function handleMessage(message) {
    const id = message.headers['message-id'];

    if (processed.has(id)) {
        return;
    }

    processed.add(id);

    processMessage(message);
}

Координация heartbeat между вкладками

Heartbeat должен отправлять только лидер.

const client = new Client({
    brokerURL: 'ws://localhost:15674/ws',
    heartbeatIncoming: 4000,
    heartbeatOutgoing: 4000
});

Если heartbeat будут отправлять все вкладки:

  • возрастет сетевой шум;
  • увеличится нагрузка на broker;
  • появятся ложные reconnect.

Обработка race conditions

Типичные race conditions:

Проблема Причина
Два лидера Одновременный election
Потеря сообщений Переключение лидера
Дублирование publish Повторная отправка
Двойные подписки Несогласованность состояния

Защита от двойного лидера

Использование timestamp

const leaderData = {
    id: TAB_ID,
    timestamp: Date.now()
};

Проверка актуальности

const current = JSON.parse(
    localStorage.getItem('leader')
);

if (
    !current ||
    current.timestamp < leaderData.timestamp
) {
    localStorage.setItem(
        'leader',
        JSON.stringify(leaderData)
    );
}

Масштабирование архитектуры

При большом количестве вкладок полезно:

  • ограничивать частоту синхронизации;
  • использовать batching;
  • объединять события;
  • минимизировать payload;
  • кешировать последние состояния;
  • использовать binary frames.

Оптимизация BroadcastChannel

Batch-отправка

const batch = [];

Буферизация

function pushMessage(message) {
    batch.push(message);
}

Отправка пачкой

setInterval(() => {
    if (!batch.length) {
        return;
    }

    channel.postMessage({
        type: 'BATCH',
        payload: [...batch]
    });

    batch.length = 0;
}, 100);

Мониторинг синхронизации

Логирование

function log(...args) {
    console.log(
        '[STOMP_SYNC]',
        ...args
    );
}

Метрики

Полезно отслеживать:

  • число вкладок;
  • число reconnect;
  • время election;
  • задержку BroadcastChannel;
  • объем сообщений;
  • число дублей;
  • частоту heartbeat.

Практическая архитектура production-решения

Наиболее устойчивой считается следующая схема:

Browser Tabs
    ↓
BroadcastChannel
    ↓
Leader Tab
    ↓
STOMP.js
    ↓
RabbitMQ / ActiveMQ / Apollo

Дополнительно:

  • heartbeat;
  • reconnect;
  • centralized subscriptions;
  • deduplication;
  • offline queue;
  • token synchronization;
  • batching;
  • leader failover.

Подобная архитектура обеспечивает:

  • минимальное число WebSocket-подключений;
  • стабильную работу STOMP.js;
  • снижение нагрузки на backend;
  • согласованное состояние интерфейса;
  • корректную работу realtime-функциональности между всеми вкладками браузера.