Як ми будували систему нотифікацій у web

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт DOU. Мене звати Родіон, і я Senior Frontend Developer в компанії SoftServe. Сьогодні хочу вам розповісти про нетривіальну задачу у вебі — як надійно нотифікувати користувачів.

Проєкт, на якому я працюю, за своєю архітектурою схожий на звичайний Uber-like проєкт. Користувач отримує замовлення, ініційоване клієнтом, і має його прийняти за обмежену кількість часу. Єдиний нюанс і головна відмінність від Uber — у нас вебзастосунок. А у вебі достовірно повідомити користувача про щось якісно і надійно — непросто.

Проблема

Уявляємо типовий сценарій: користувач відкрив застосунок у кількох вкладках і в процесі очікування на замовлення — переключився на YouTube.

Саме в цей момент приходить повідомлення: звуковий сигнал пролунав, але через відео цей звук був пропущений; повідомлення зʼявилося — але в іншій вкладці. Можливість заробити упущена.

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

Що ми маємо та від чого йдемо

Для початку необхідно зрозуміти наш початковий стан та те, як ми справляємося з цим зараз.

У нашому випадку нотифікації працюють виключно на одній сторінці — home page. Використовується pull-підхід, де кожні 30 секунд ми йдемо на сервер та запитуємо про нові події.

У разі наявності такої події ми:

  • програємо звук;
  • додаємо (N) до document.title, щоб було видно в назві вкладки, що там щось відбулося;
  • відображаємо кнопку Accept на сторінці.

На прийняття реквесту у користувача є 90 секунд. Але при даному підході це 90 секунд у кращому випадку, у гіршому — приблизно 61 секунда, адже дані ми отримуємо кожні 30 секунд.

Змінюємо стратегію отримання даних

З вищевказаної інформації можна зробити висновок, що pull-підхід у нашому варіанті недостатньо ефективний.

Враховуючи, що декілька провайдерів послуг можуть боротися за одне замовлення, хтось із них матиме перевагу через різницю в тому, хто і коли побачить нотифікацію — а різниця може сягати 29 секунд.

Для подібних кейсів зазвичай обирають між двома технологіями: WebSockets і SSE (Server-Sent Events). Отже, порівняємо їх.

КритерійWebSocketsSSE
Тип з’єднанняДвостороннєОдностороннє: server → client
Основне призначенняЧати, realtime interactionНотифікації, live updates
ПротоколWebSocketHTTP
Підтримка binary dataТакНі
МасштабуванняСкладнішеПростіше
Підтримка keep-aliveЧерез ping/pongВбудована: EventSource

Для нашого випадку було критично важливим не вносити високоризиковані зміни у весь бізнес-флоу фічі, а мінімальними зусиллями покращити UX. Також нам було абсолютно достатньо одностороннього зʼєднання, тому ми зупинилися на SSE.

Перша проблема — пулінг, який призводив до нерівних умов для юзерів — вирішена. Ми не знизили latency до нуля, але SSE навіть під високим навантаженням дає рівніші результати, ніж 30-секундний пулінг різних юзерів.

Система нотифікацій, а не просто повідомлення

Після переходу на SSE ми вирішили важливу частину проблеми — подія стала доходити до клієнта швидше й рівномірніше для всіх користувачів. Але дуже швидко стало зрозуміло, що realtime delivery — це лише перший крок.

Отримати подію в браузері ще не означає привернути увагу користувача.

Вкладка може бути у фоні. Користувач може дивитися відео в іншому вікні. Звук може бути заглушений або заблокований браузером. А in-app повідомлення взагалі не допоможе, якщо користувач зараз не дивиться на сторінку.

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

КаналСильна сторонаСлабке місце
ЗвукШвидко привертає увагуКористувач може не почути; вкладка може бути заглушена; browsers autoplay policy
Browser Notification APIПрацює поза активною вкладкоюМоже бути заблокований браузером або ОС
In-app повідомленняНайкращий UX, якщо вкладка активнаНе допомагає, якщо користувач в іншій вкладці
Динамічний faviconНе залежить від налаштувань нотифікацій на рівні ОС, браузераПрацює тільки якщо вкладка відкрита і видима серед інших
Missed events summaryДопомагає після поверненняСам по собі не привертає увагу

Жоден із цих каналів не є ідеальним. Але разом вони створюють систему, яка значно підвищує шанс, що користувач помітить важливу подію.

Саме тому наступним кроком було не просто «додати ще одну нотифікацію», а навчитися координувати ці канали між собою: коли показувати in-app повідомлення, коли системну нотифікацію, коли запускати fallback-и, і як не перетворити це все на лавину дублюючих alert-ів.

Browser Notification API — зручно, але ненадійно

Перший очевидний інструмент — Web Notifications API. Він дозволяє показувати системні повідомлення, навіть коли вкладка неактивна або вікно браузера не у фокусі уваги користувача.

async function requestPermission() {
  if (!('Notification' in window)) {
    return false;
  }

  if (Notification.permission === 'granted') {
    return true;
  }

  if (Notification.permission === 'denied') {
    return false;
  }

  const permission = await Notification.requestPermission();
  return permission === 'granted';
}

async function sendBrowserNotification(title, body) {
  const hasPermission = await requestPermission();

  if (!hasPermission) {
    return;
  }

  new Notification(title, {
    body,
    icon: '/icon.png',
    requireInteraction: true, // не зникає автоматично
  });
}

Виглядає добре, але є нюанс, який легко пропустити — проблема на рівні ОС.

Notification.permission повертає "granted", навіть якщо користувач вимкнув нотифікації від браузера в налаштуваннях операційної системи. Ми з браузера просто не маємо доступу до цього рівня інформації.

Теоретично можна було б слухати onshow подію на нотифікації:

notification.onshow = () => {
  console.log('Notification shown');
};

Але проблема в тому, що onshow буде викликаний, навіть якщо ОС не показала нотифікацію. Тобто браузер впевнений у тому, що його нотифікація була показана, але насправді ОС заблокувала її.

Тому ми зрозуміли: Browser Notifications не можна вважати надійним механізмом доставки інформації.

Обираємо канал нотифікації на основі видимості

Базуючись на інформації вище, робимо припущення, що нам необхідно покрити кейс, коли у юзера вимкнені нотифікації на рівні ОС.

Типова помилка, яку можна тут допустити, — запускати одразу всі канали оповіщення: browser-нотифікації, in-app-нотифікації, звук, динамічний favicon. У результаті користувач отримує лавину дублюючих повідомлень.

Краще рішення — обирати основний канал залежно від стану вкладки.

Наприклад:

function notify(message) {
  if (document.visibilityState === 'visible') {
    showInAppBanner(message); // in-app нотифікація
  } else {
    showBrowserNotification(message); // browser нотифікація
  }
}

І окремо — обробляємо повернення на вкладку:

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    showMissedEventsSummary();
  }
});

Таким чином:

  • активна вкладка → in-app нотифікація;
  • фонова вкладка → browser нотифікація;
  • повернення юзера на сторінку → дайджест пропущених подій.

Лавину нотифікацій уникли.

Проблема кількох вкладок

Якщо SSE підключається на кожній сторінці застосунку, то при відкритих чотирьох вкладках — три з яких неактивні — одна й та сама подія викличе три однакових системних повідомлення.

Це точно не виглядає як гарний User Experience.

Для вирішення цієї проблеми ми використали SharedWorker.

Що таке SharedWorker і чому не Service Worker

SharedWorker — це один спільний JS-воркер, який існує в одному екземплярі на весь origin і живе доти, доки хоча б одна вкладка відкрита.

Без SharedWorker:

З SharedWorker:

Одне SSE-з’єднання, одне місце для прийняття рішень, жодних дублікатів.

Чому не Service Worker?

Це питання виникає одразу, тому складемо порівняльну таблицю.

КритерійSharedWorkerService Worker
Кількість екземплярівОдин на originОдин на origin
Живе без вкладок
Підтримує SSE⚠️ обмежено
Push без браузера
СкладністьСередняВисока
Потребує HTTPS✅ обов’язково

Service Worker — потужніший інструмент: він живе навіть тоді, коли всі вкладки закриті, і може отримувати push-повідомлення від сервера. Але за це доводиться платити:

  • обовʼязковий HTTPS;
  • складніший lifecycle: install → activate → fetch — потрібно явно керувати версіями і кешем;
  • Push API потребує окремої серверної інфраструктури: Web Push protocol, VAPID-ключі;
  • SSE всередині Service Worker працює з обмеженнями — він не може тримати постійне зʼєднання, бо браузер може кинути воркера в сон для економії ресурсів.

SharedWorker вирішує конкретну задачу — координацію між вкладками — і робить це просто. Якщо ваш юзкейс: «користувач має хоча б одну відкриту вкладку», SharedWorker є достатнім і набагато простішим рішенням.

Але Service Worker все ж варто розглянути детальніше, якщо вам потрібно сповіщати користувача навіть тоді, коли браузер згорнутий або всі вкладки закриті. Також це актуально, якщо ваш сервіс орієнтований на мобільний веб або PWA. У такому випадку зв’язка Service Worker + Push API буде правильним вибором, але це вже тема для окремої статті.

Реалізація

// shared-worker.js
const ports = new Map(); // id -> { port, isVisible }
let eventSource = null;

function connectSSE() {
  eventSource = new EventSource('/api/events');

  eventSource.onmessage = (e) => {
    handleEvent(JSON.parse(e.data));
  };

  // Важливо: перепідключатись при розриві
  eventSource.onerror = () => {
    eventSource.close();
    setTimeout(connectSSE, 3000);
  };
}

function handleEvent(event) {
  // Шукаємо активну вкладку
  const activePort = [...ports.values()].find((p) => p.isVisible);

  if (activePort) {
    // Є активна вкладка — вона покаже in-page банер
    activePort.port.postMessage({ type: 'SHOW_INAPP', event });
  } else {
    // Всі вкладки у фоні — одна з них тригерне системне повідомлення
    const anyPort = [...ports.values()][0];
    if (anyPort) {
      anyPort.port.postMessage({ type: 'SHOW_BROWSER', event });
    }
  }
}

self.onconnect = ({ ports: [port] }) => {
  const id = crypto.randomUUID();
  ports.set(id, { port, isVisible: false });

  port.onmessage = ({ data }) => {
    if (data.type === 'VISIBILITY') {
      ports.get(id).isVisible = data.visible;
    }
  };

  // У production варто додати cleanup неактивних ports. 
  // MessagePort не дає надійного lifecycle hook-а на всі сценарії закриття вкладки, 
  // тому зазвичай це вирішують через heartbeat/ping-pong і timeout.
  port.onclose = () => ports.delete(id);

  // Запускаємо SSE тільки один раз
  if (!eventSource) connectSSE();
  port.start();
};

На стороні вкладки

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

// Повідомляємо воркеру про видимість при старті і при зміні
function reportVisibility() {
  worker.port.postMessage({
    type: 'VISIBILITY',
    visible: document.visibilityState === 'visible',
  });
}

reportVisibility();
document.addEventListener('visibilitychange', reportVisibility);

// Реагуємо на команди від воркера
worker.port.onmessage = ({ data }) => {
  if (data.type === 'SHOW_INAPP') {
    showInPageBanner(data.event);
    playSound();
  }

  if (data.type === 'SHOW_BROWSER') {
    sendBrowserNotification(data.event.message);
    startFaviconBlink();
    playSound();
  }
};

worker.port.start();

Мигаючий фавікон — страховка без залежності від ОС

Browser notification може не зʼявитися. Звук користувач може не почути. Але якщо вкладка відкрита — favicon залишається додатковим каналом, який не залежить від жодних системних дозволів.

Ідея проста: коли приходить подія, а вкладка неактивна — починаємо мигати фавіконом. Користувач помічає зміну серед інших вкладок навіть периферійним зором. Це не гарантія, але це корисний незалежний шар.

let blinkInterval = null;
let originalFavicon = null;

function startFaviconBlink() {
  const link = document.querySelector("link[rel~='icon']");
  originalFavicon = link.href;

  let isAlert = false;

  blinkInterval = setInterval(() => {
    link.href = isAlert ? originalFavicon : '/favicon-alert.png';
    isAlert = !isAlert;
  }, 800);
}

favicon-alert.png — це звичайна іконка з червоною крапкою зверху. Достатньо двох файлів.

Обовʼязково зупиняємо блимання, коли юзер повертається на вкладку:

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    stopFaviconBlink();
    showMissedEventsSummary();
  }
});

Мигання фавікону — це не заміна нотифікаціям, а додатковий шар, який варто запускати завжди при фоновій події, незалежно від того, чи вдалося показати системне повідомлення.

Важливі нюанси

Звук у фонових вкладках може не відтворюватися

Браузери можуть блокувати autoplay звуків у вкладках. Чому це виникає, можна почитати в Autoplay policy.

Але, якщо коротко, autoplay дозволений у вкладках, де:

  • користувач вже взаємодіяв з origin: click, tap тощо;
  • користувач раніше достатньо часто відвідував цей сайт, щоб браузер (Chrome) довіряв йому і дозволяв автоматично відтворювати мультимедіа зі звуком;
  • користувач встановив сайт як PWA.

SharedWorker не переживає закриття браузера

Якщо всі вкладки закриті — воркер гине, SSE обривається. Це нормально для більшості застосунків.

Але якщо потрібні сповіщення навіть без відкритого браузера — варто звернути увагу на Service Worker + Push API (окрема велика тема).

visibilitychange при закритті браузера — ненадійний

Якщо користувач закриває весь браузер, а не окрему вкладку, подія visibilitychange може не спрацювати.

Для точнішого відстеження відключення вкладки використовуйте pagehide:

window.addEventListener('pagehide', () => {
  worker.port.postMessage({ type: 'VISIBILITY', visible: false });
});

Але насправді нас це мало турбує, бо при відкритті нового вікна браузера Shared Worker буде створений з нуля.

Клік на browser notification — перенаправлення

Гарною практикою вважається при кліку на системне повідомлення фокусувати вже відкриту вкладку, а не відкривати нову:

notification.onclick = () => {
  window.focus();
  // або navigateTo('/dashboard')
};

Але сучасні браузери можуть обмежувати window.focus() із міркувань безпеки, якщо це викликано не з контексту Service Worker.

Фінальна архітектура

Висновок

У веб-нотифікаціях сама доставка події ще не гарантує, що користувач її помітить.

Ми можемо отримати подію майже миттєво через SSE, але це не означає, що користувач зараз дивиться на потрібну вкладку. Ми можемо викликати Browser Notification API, але це не означає, що операційна система справді покаже повідомлення. Ми можемо відтворити звук, але це не означає, що користувач його почує.

Саме тому надійна система нотифікацій у вебі — це не один API і не один «правильний» канал доставки. Це координація між realtime delivery, відкритими вкладками, станом видимості сторінки, browser/OS-level permissions, fallback UI та реальною поведінкою користувача.

У нашому випадку комбінація SSE, SharedWorker, visibility-based routing і додаткових шарів привернення уваги стала хорошим компромісом між складністю реалізації та надійністю. Вона не дає 100% гарантії, що користувач точно побачить подію, але суттєво підвищує шанси на це.

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

👍ПодобаєтьсяСподобалось15
До обраногоВ обраному8
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Нотификация — это вот то малюсенькое окошечко с мелким текстом, которое иногда всплывает в углу экрана, висит несколько секунд, а потом исчезает? Никогда не понимал пользы от этого. Потому что в 90% случаев я его не замечаю (у меня 27 дюймов и нужно повернуть голову, чтоб сфокусироваться на окошечке уведомления), а в оставшихся 10% я чаще всего не успеваю его прочесть :))

ИМХО — если ждешь уведомления, то никакие напоминалки не нужны — ты сам заинтересован его увидеть (ты не будешь переключаться в другой таб, или сделаешь страницу отдельным окном, а сам будешь смотреть свой долбаный ютуб в другом окне рядом). А если не ждешь — то это уже не уведомление, а спам :))

ИМХО зря потраченные усилия.

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

💯

Чекати замовлення не означає годинами сидіти й дивитися в одну вкладку
Людина може бути онлайн, але паралельно читати щось, дивитися відео чи відповідати в телеграм. І якщо замовлення живе 90 секунд, то це вже не сценарій «колись прочитає», а «помітив зараз або втратив гроші»
Тому тут важлива не факт доставки повідомлення, а докласти максимально зусиль, щоб користувач побачив його

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

Мені здається, автор не до кінця розкрив де це застосовується.

повідомлення зʼявилося — але в іншій вкладці. Можливість заробити упущена.

Це може бути якийсь маркетплейс послуг, наприклад таксі або доставки, де замовлення розбирають за лічені секунди.

Тому надсилати повідомлення на пошту — яка працює не миттєво — тут не варіант.

Email має latency від кількох секунд до кількох хвилин і це в кращому випадку, якщо пошта відкрита і людина дивиться в неї прямо зараз. У нас є 90 секунд до того як замовлення піде до настпуних провайдерів
Пошта тут просто фізично може не встигнути. Це та ж причина, чому Uber, Glovo не надсилають водіям/курʼєрам листи — потрібен ріалтайм-канал із гарантованим (наскільки це можливо) привертанням уваги в моменті

Може бути корисно згадати ще кілька можливостей Service Worker:

* Може визначати, чи вкладка зараз активна:

const allClients = await clients.matchAll({
    includeUncontrolled: true,
    type: 'window',
  });

  const focusedClient = allClients.find((client) => client.focused);

  if (focusedClient) {
    focusedClient.postMessage({
      type: 'SHOW_IN_APP_NOTIFICATION',
      payload,
    });
  } else {
    self.registration.showNotification(title, options);
  }

А в застосунку отримати повідомлення:

navigator.serviceWorker.addEventListener('message', (event) => {
    if (event.data.type === 'SHOW_IN_APP_NOTIFICATION') {
      showNotification(event.data.payload);
    }
  });

Тобто, якщо користувач зараз у застосунку, показуємо in-app notification. Якщо ні, системне сповіщення.

* Клієнт може передавати дані в Service Worker:

 navigator.serviceWorker.controller?.postMessage({
    type: 'SET_USER',
    user,
  });

А в Service Worker їх отримувати:

  self.addEventListener('message', (event) => {
    const data = event.data;
  });

Можна дублювати нотифікацію на пошту. Теж не ідеально, але збільшує шанси що юзер побачить

у парі випадків бачив використання вайберу одразу, бо він походу ш 90% населення стоїть)

Підписатись на коментарі