Реалізація Real-Time Notifications (WebSocket/SSE) на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Real-Time Notifications (WebSocket/SSE) на сайті
Середня
~3-5 робочих днів
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація Real-Time Notifications (WebSocket/SSE) на веб-сайті

Сповіщення в реальному часі — один з найбільш частих запитів. Технічно між WebSocket та SSE принципова різниця: SSE — одностороння потокова передача від сервера до клієнта через звичайний HTTP, WebSocket — двосторонній канал. Для сповіщень часто достатньо SSE.

SSE vs WebSocket: коли що вибирати

SSE підходить, якщо потрібно тільки отримувати події з сервера: нові повідомлення, оновлення статусу, оповіщення. Працює через стандартний HTTP/2, підтримує автоматичне переподключення, не потребує додаткових бібліотек на клієнті.

WebSocket потрібен, якщо клієнт також надсилає дані в реальному часі: чат, ігри, спільне редагування.

SSE: Server → Client (одностороння, HTTP)
WebSocket: Server ↔ Client (двостороння, WS protocol)

SSE: реалізація на Node.js/Express

// server/routes/notifications.ts
import { Router, Request, Response } from 'express';
import { authMiddleware } from '../middleware/auth';

const router = Router();

// Map userId -> Set<Response>
const clients = new Map<string, Set<Response>>();

router.get('/stream', authMiddleware, (req: Request, res: Response) => {
  const userId = req.user!.id;

  res.writeHead(200, {
    'Content-Type':  'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection':    'keep-alive',
    'X-Accel-Buffering': 'no',  // важливо для nginx
  });

  // Heartbeat кожні 30 секунд — інакше proxy/браузер розірве з'єднання
  const heartbeat = setInterval(() => {
    res.write(':heartbeat\n\n');
  }, 30_000);

  // Реєстрація клієнта
  if (!clients.has(userId)) clients.set(userId, new Set());
  clients.get(userId)!.add(res);

  // Початковий снімок непрочитаних
  getUnreadNotifications(userId).then((notifications) => {
    res.write(sseEvent('init', notifications));
  });

  req.on('close', () => {
    clearInterval(heartbeat);
    clients.get(userId)?.delete(res);
    if (clients.get(userId)?.size === 0) clients.delete(userId);
  });
});

function sseEvent(type: string, data: unknown, id?: string): string {
  let msg = '';
  if (id) msg += `id: ${id}\n`;
  msg += `event: ${type}\n`;
  msg += `data: ${JSON.stringify(data)}\n\n`;
  return msg;
}

// Публічна функція для надсилання сповіщення користувачу
export function pushNotification(userId: string, notification: Notification) {
  const userClients = clients.get(userId);
  if (!userClients) return;

  const msg = sseEvent('notification', notification, notification.id);
  userClients.forEach((res) => res.write(msg));
}

export default router;

Клієнтська частина: EventSource

class NotificationService {
  private es: EventSource | null = null;
  private reconnectDelay = 1000;

  connect() {
    this.es = new EventSource('/api/notifications/stream', {
      withCredentials: true,
    });

    this.es.addEventListener('init', (e) => {
      const notifications = JSON.parse(e.data);
      notificationStore.setAll(notifications);
    });

    this.es.addEventListener('notification', (e) => {
      const notification = JSON.parse(e.data);
      notificationStore.add(notification);
      this.showToast(notification);
    });

    this.es.addEventListener('error', () => {
      this.es?.close();
      // Експоненціальний backoff
      setTimeout(() => {
        this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000);
        this.connect();
      }, this.reconnectDelay);
    });

    this.es.addEventListener('open', () => {
      this.reconnectDelay = 1000; // скидання при успішному з'єднанні
    });
  }

  disconnect() {
    this.es?.close();
    this.es = null;
  }

  private showToast(notification: Notification) {
    // інтеграція з бібліотекою toast на вибір (sonner, react-hot-toast, тощо)
    toast(notification.title, {
      description: notification.body,
      action: notification.actionUrl
        ? { label: 'Відкрити', onClick: () => navigate(notification.actionUrl!) }
        : undefined,
    });
  }
}

WebSocket-варіант: інтеграція з чергою

Для production сповіщення не надсилаються прямо з запиту до SSE-клієнта — між прикладним шаром та доставкою стоїть черга:

HTTP Request → DB збереження → Redis Publish → WebSocket Server → Client
// Публікація події (з будь-якого сервісу/робітника)
import { createClient } from 'redis';

const pub = createClient({ url: process.env.REDIS_URL });
await pub.connect();

async function emitNotification(userId: string, notification: Notification) {
  await db.notifications.create({ data: notification });
  await pub.publish(
    `notifications:${userId}`,
    JSON.stringify(notification)
  );
}

// WebSocket сервер підписується через окремий Redis subscriber
const sub = createClient({ url: process.env.REDIS_URL });
await sub.connect();

io.on('connection', (socket) => {
  const userId = socket.data.userId;

  // Підписка на особистий канал
  sub.subscribe(`notifications:${userId}`, (message) => {
    socket.emit('notification', JSON.parse(message));
  });

  socket.on('notification:read', async (notificationId: string) => {
    await db.notifications.update({
      where: { id: notificationId },
      data:  { readAt: new Date() },
    });
  });

  socket.on('disconnect', () => {
    sub.unsubscribe(`notifications:${userId}`);
  });
});

Структура сповіщення

interface Notification {
  id:         string;
  userId:     string;
  type:       'comment' | 'mention' | 'order' | 'system' | 'alert';
  title:      string;
  body:       string;
  actorId?:   string;      // хто ініціював
  entityType?: string;     // 'post' | 'order' | ...
  entityId?:  string;
  actionUrl?: string;
  imageUrl?:  string;
  readAt?:    Date;
  createdAt:  Date;
}

Групування та батчинг

Якщо за секунду надходить багато подій (масова розсилка, потік даних), клієнт отримує окремо SSE-event для кожного. Краще групувати:

// Серверний буфер: 200ms debounce на flush
const pendingByUser = new Map<string, Notification[]>();

function bufferNotification(userId: string, notification: Notification) {
  if (!pendingByUser.has(userId)) {
    pendingByUser.set(userId, []);
    setTimeout(() => flushUser(userId), 200);
  }
  pendingByUser.get(userId)!.push(notification);
}

function flushUser(userId: string) {
  const batch = pendingByUser.get(userId) ?? [];
  pendingByUser.delete(userId);

  if (batch.length === 1) {
    pushToClient(userId, sseEvent('notification', batch[0]));
  } else {
    pushToClient(userId, sseEvent('notifications:batch', batch));
  }
}

Масштабування SSE

SSE тримає HTTP-з'єднання відкритим — кожний підключений користувач займає один file descriptor. Node.js комфортно тримає 10k+ з'єднань, але при горизонтальному масштабуванні (кілька інстансів) користувач може бути підключений до інстансу A, а сповіщення згенеровано інстансом B. Redis Pub/Sub вирішує це — кожний інстанс підписаний на всі канали й доставляє тільки своїм клієнтам.

Для nginx: проксування SSE потребує відключення буферизації:

location /api/notifications/stream {
    proxy_pass http://app_backend;
    proxy_buffering       off;
    proxy_cache           off;
    proxy_read_timeout    3600s;
    proxy_set_header Connection '';
    chunked_transfer_encoding on;
}

Реалізація SSE-сповіщень з Redis: 2–3 дні. Додавання WebSocket з двостороннією логікою (read receipts, typing): ще один день.