Система пользовательских уведомлений в реальном времени строится вокруг нескольких ключевых компонентов:
Типичная схема взаимодействия:
Браузер → WebSocket → STOMP Broker → Notification Service
↓
Пользовательские очереди
В качестве брокера сообщений чаще всего используются:
Библиотека STOMP.js выступает транспортным уровнем между frontend-приложением и брокером сообщений.
Современная версия библиотеки распространяется через пакет
@stomp/stompjs.
npm install @stomp/stompjs
Для браузеров и серверов без полноценной поддержки WebSocket часто используется SockJS.
npm install sockjs-client
Минимальная конфигурация STOMP-клиента:
import { Client } fr om '@stomp/stompjs';
const client = new Client({
brokerURL: 'ws://localhost:8080/ws',
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
client.onConn ect = () => {
console.log('Connected');
};
client.activate();
| Параметр | Назначение |
|---|---|
| brokerURL | URL WebSocket endpoint |
| reconnectDelay | Интервал переподключения |
| heartbeatIncoming | Входящий heartbeat |
| heartbeatOutgoing | Исходящий heartbeat |
| debug | Логирование кадров |
| connectHeaders | Заголовки авторизации |
Уведомления почти всегда являются персонализированными, поэтому соединение требует аутентификации.
const client = new Client({
brokerURL: 'ws://localhost:8080/ws',
connectHeaders: {
Authorization: 'Bearer ' + token
}
});
Иногда токен передают через URL:
const socket = new WebSocket(
`ws://localhost:8080/ws?token=${token}`
);
Однако такой подход менее безопасен, поскольку URL может попасть в логи сервера.
После успешного подключения пользователь подписывается на персональный канал.
client.onConn ect = () => {
client.subscribe('/user/queue/notifications', (message) => {
const notification = JSON.parse(message.body);
console.log(notification);
});
};
Обычно уведомление содержит:
{
"id": 153,
"type": "NEW_MESSAGE",
"title": "Новое сообщение",
"text": "Пользователь отправил сообщение",
"createdAt": "2026-05-22T14:10:00",
"read": false
}
Система может разделять уведомления по типам:
| Тип | Назначение |
|---|---|
| NEW_MESSAGE | Новые сообщения |
| SYSTEM | Системные события |
| SECURITY | Безопасность |
| ORDER_STATUS | Изменение заказа |
| COMMENT | Комментарии |
| WARNING | Предупреждения |
Лучше избегать логики непосредственно внутри callback-функции.
client.subscribe('/user/queue/notifications', (message) => {
const data = JSON.parse(message.body);
renderNotification(data);
playSound();
updateCounter();
saveToStore(data);
});
client.subscribe('/user/queue/notifications', handleNotification);
function handleNotification(message) {
const data = parseNotification(message);
notificationStore.add(data);
notificationUI.render(data);
notificationAudio.play(data.type);
}
Для крупных приложений создают отдельный сервис уведомлений.
class NotificationService {
constructor(client) {
this.client = client;
}
subscribe() {
this.client.subscribe(
'/user/queue/notifications',
this.handle.bind(this)
);
}
handle(message) {
const notification = JSON.parse(message.body);
this.show(notification);
}
show(notification) {
console.log(notification);
}
}
STOMP поддерживает acknowledgements.
client.subscribe(
'/user/queue/notifications',
(message) => {
const notification = JSON.parse(message.body);
processNotification(notification);
message.ack();
},
{
ack: 'client'
}
);
Без подтверждений брокер считает сообщение доставленным сразу после отправки.
Если браузер:
то уведомление может потеряться.
ACK позволяет гарантировать обработку.
При ошибке обработки сообщение можно вернуть обратно в очередь.
client.subscribe(
'/user/queue/notifications',
(message) => {
try {
const data = JSON.parse(message.body);
processNotification(data);
message.ack();
} catch (error) {
message.nack();
}
},
{
ack: 'client'
}
);
Для систем уведомлений reconnect является обязательным.
const client = new Client({
brokerURL: 'ws://localhost:8080/ws',
reconnectDelay: 5000
});
STOMP.js:
client.onDisconn ect = () => {
console.log('Disconnected');
};
client.onWebSocketEr ror = (error) => {
console.error(error);
};
client.onStompEr ror = (frame) => {
console.error(frame.headers['message']);
console.error(frame.body);
};
Пользователь должен понимать текущее состояние соединения.
const connectionState = {
connected: false
};
client.onConn ect = () => {
connectionState.connected = true;
};
client.onDisconn ect = () => {
connectionState.connected = false;
};
Одна из самых распространённых задач.
class NotificationStore {
constructor() {
this.notifications = [];
}
add(notification) {
this.notifications.unshift(notification);
}
unreadCount() {
return this.notifications.filter(
item => !item.read
).length;
}
}
client.publish({
destination: '/app/notifications/read',
body: JSON.stringify({
notificationId: 55
})
});
При высокой активности сервер может отправлять десятки событий.
Вместо:
Иван отправил сообщение
Анна отправила сообщение
Павел отправил сообщение
Формируется:
3 новых сообщения
function groupNotifications(items) {
return items.reduce((groups, item) => {
const key = item.type;
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
}, {});
}
Массовые события способны перегружать UI.
let timer = null;
function scheduleRender() {
clearTimeout(timer);
timer = setTimeout(() => {
renderNotifications();
}, 200);
}
import { useEffect } fr om 'react';
function useNotifications(client) {
useEffect(() => {
if (!client) {
return;
}
const subscription = client.subscribe(
'/user/queue/notifications',
(message) => {
const data = JSON.parse(message.body);
console.log(data);
}
);
return () => {
subscription.unsubscribe();
};
}, [client]);
}
import { onMounted, onUnmounted } fr om 'vue';
export function useNotifications(client) {
let subscription = null;
onMounted(() => {
subscription = client.subscribe(
'/user/queue/notifications',
onMessage
);
});
onUnmounted(() => {
if (subscription) {
subscription.unsubscribe();
}
});
}
export function notificationReceived(notification) {
return {
type: 'NOTIFICATION_RECEIVED',
payload: notification
};
}
client.subscribe(
'/user/queue/notifications',
(message) => {
const notification = JSON.parse(message.body);
store.dispatch(
notificationReceived(notification)
);
}
);
Иногда система комбинирует:
| Состояние | Канал |
|---|---|
| Пользователь онлайн | STOMP |
| Вкладка закрыта | Push |
| Пользователь офлайн | Push |
| Пользователь активен | WebSocket |
Heartbeat нужен для контроля живости соединения.
const client = new Client({
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000
});
Клиент и сервер обмениваются heartbeat-пакетами.
Если heartbeat перестал приходить:
Иногда reconnect приводит к повторной доставке.
const processed = new Set();
function handleNotification(message) {
const notification = JSON.parse(message.body);
if (processed.has(notification.id)) {
return;
}
processed.add(notification.id);
renderNotification(notification);
}
Визуально уведомления часто показываются последовательно.
class NotificationQueue {
constructor() {
this.queue = [];
this.active = false;
}
push(notification) {
this.queue.push(notification);
this.next();
}
next() {
if (this.active) {
return;
}
const notification = this.queue.shift();
if (!notification) {
return;
}
this.active = true;
showNotification(notification);
setTimeout(() => {
this.active = false;
this.next();
}, 3000);
}
}
STOMP.js отлично сочетается с нативными уведомлениями браузера.
await Notification.requestPermission();
new Notification('Новое сообщение', {
body: 'Поступило новое сообщение'
});
const audio = new Audio('/sounds/notification.mp3');
audio.play();
| Проблема | Причина |
|---|---|
| Утечки памяти | Неотписанные subscriptions |
| Фризы UI | Частые рендеры |
| Дубли | Reconnect |
| Потеря сообщений | Отсутствие ACK |
| Рост RAM | Большой список уведомлений |
class NotificationStore {
constructor(lim it = 100) {
this.lim it = lim it;
this.items = [];
}
add(notification) {
this.items.unshift(notification);
if (this.items.length > this.limit) {
this.items.pop();
}
}
}
Одна из самых критичных задач.
let subscription = null;
function init() {
subscription = client.subscribe(
'/user/queue/notifications',
onMessage
);
}
function destroy() {
if (subscription) {
subscription.unsubscribe();
}
}
При росте нагрузки архитектура усложняется.
Frontend
↓
WebSocket Gateway
↓
Message Broker
↓
Notification Service
↓
Database
Хорошая практика — использовать разные destination.
/user/queue/messages
/user/queue/security
/user/queue/system
/user/queue/orders
Вместо одного сообщения сервер может отправлять массив.
[
{
"id": 1,
"text": "Message 1"
},
{
"id": 2,
"text": "Message 2"
}
]
client.subscribe(
'/user/queue/notifications',
(message) => {
const notifications = JSON.parse(message.body);
notifications.forEach(addNotification);
}
);
Иногда сервер способен отправить тысячи событий.
let received = 0;
setInterval(() => {
received = 0;
}, 1000);
function onMessage(message) {
received++;
if (received > 100) {
return;
}
processMessage(message);
}
Для диагностики полезно включать debug.
const client = new Client({
debug(str) {
console.log(str);
}
});
Если уведомления содержат HTML:
import DOMPurify from 'dompurify';
const safeHTML = DOMPurify.sanitize(
notification.text
);
import { Client } from '@stomp/stompjs';
class NotificationManager {
constructor(token) {
this.store = [];
this.processed = new Set();
this.client = new Client({
brokerURL: 'ws://localhost:8080/ws',
reconnectDelay: 5000,
connectHeaders: {
Authorization: `Bearer ${token}`
}
});
this.client.onConn ect = this.onConnect.bind(this);
this.client.activate();
}
onConnect() {
this.client.subscribe(
'/user/queue/notifications',
this.onMessage.bind(this),
{
ack: 'client'
}
);
}
onMessage(message) {
try {
const notification = JSON.parse(message.body);
if (this.processed.has(notification.id)) {
message.ack();
return;
}
this.processed.add(notification.id);
this.store.unshift(notification);
this.render(notification);
message.ack();
} catch (error) {
message.nack();
}
}
render(notification) {
console.log(notification);
}
}