Клиент оформил заказ в интернет-магазине и закрыл вкладку. Через час приходит сообщение о том, что посылка передана курьеру, и человек открывает приложение одним касанием. Ни письма в ящике, ни ожидания, ни поиска. Так выглядит работа службы доставки сообщений в браузере, и за этой простотой скрывается связка из трёх механизмов и несколько решений, от которых зависит, будут ли уведомления приходить вообще.

Служба доставки работает через сервис-воркер и не зависит от открытой страницы

Обычное веб-приложение живо, пока открыта вкладка. Закрыл её, и никакие таймеры не сработают, никакие обновления не придут. Служба доставки сообщений ломает это правило: она позволяет получать данные от сервера, даже когда приложение не загружено и не находится на переднем плане.

Основой служит сервис-воркер, тот самый фоновый сценарий, который перехватывает сетевые запросы. Он работает отдельно от страниц и продолжает жить, когда вкладок нет. Именно он подписывается на доставку и принимает входящие сообщения.

Механика подписки устроена так. Клиент обращается к службе доставки браузера и получает объект подписки. Внутри лежит адрес, по которому нужно отправлять сообщения, и пара ключей для шифрования. Адрес уникален для конкретного браузера и устройства, ключи нужны, чтобы содержимое сообщения не смог прочитать никто по пути.

Полученную подписку клиент отправляет своему серверу и сохраняет. Дальше серверная часть при необходимости отправляет запрос в службу доставки, а та передаёт сообщение в браузер. Браузер будит сервис-воркер, и тот показывает уведомление.

Важное следствие состоит в том, что сообщение само по себе не обязано содержать полезные данные. Часто в нём лежит только сигнал о том, что что-то произошло, а сервис-воркер затем обращается к серверу за подробностями. Такой подход экономит размер сообщения и позволяет показать актуальные сведения.

Запрос разрешения определяет всё, и его легко испортить

Прежде чем что-либо отправить, нужно получить согласие пользователя. Разрешение запрашивается у человека и привязано к источнику страницы.

Ошибка номер один встречается почти в каждом втором проекте. Приложение показывает запрос сразу при загрузке страницы, до того как человек понял, что это за сайт и зачем ему уведомления. В лучшем случае он откажет, в худшем заблокирует навсегда. Восстановить разрешение после отказа почти невозможно: браузеры намеренно усложняют повторный запрос.

Правильный порядок другой. Сначала человек совершает осмысленное действие: оформляет заказ, подписывается на отслеживание товара, добавляет что-то в избранное. Затем приложение объясняет своими словами, какие уведомления будут приходить, и только после этого показывает системный запрос.

async function askPermission() {
  if (!("Notification" in window)) {
    return "unsupported";
  }

  const current = Notification.permission;
  if (current !== "default") {
    return current;
  }

  const agreed = confirm("Присылать уведомления о статусе заказа?");
  if (!agreed) {
    return "dismissed";
  }

  return Notification.requestPermission();
}

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

Третий момент связан с пояснением. Сухой системный текст "разрешить уведомления" ничего не говорит. Короткая фраза перед запросом способна заметно повысить долю согласий.

Подписка оформляется с открытым ключом и опцией видимости

Сама подписка выполняется методом у менеджера доставки. Ему передаются два параметра.

Первый параметр подтверждает, что все сообщения будут показаны пользователю. Это требование политики браузеров: скрытая рассылка данных не допускается. Практический смысл в том, что приложение не может использовать доставку для фоновой синхронизации без показа уведомления.

Второй параметр это открытый ключ сервера. Он позволяет службе доставки убедиться, что сообщение отправляет именно владелец приложения. Ключ передаётся в виде строки в кодировке для адресов или в виде буфера.

const VAPID_PUBLIC_KEY = "BEl62iUYgUivxIkv69yViEuiBIa-...";

function urlBase64ToUint8Array(base64String) {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  const raw = atob(base64);
  return Uint8Array.from([...raw].map((char) => char.charCodeAt(0)));
}

async function subscribe() {
  const registration = await navigator.serviceWorker.ready;
  const existing = await registration.pushManager.getSubscription();
  if (existing) {
    return existing;
  }

  return registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });
}

Проверка существующей подписки обязательна. Без неё повторный вызов создаст новую подписку, а старая останется на сервере и продолжит получать сообщения. Пользователь получит два одинаковых уведомления, а список подписок распухнет.

Хранение подписки на сервере требует осторожности. Она содержит адрес доставки, и утечка этого адреса позволяет отправить человеку произвольное уведомление. Поэтому подписка привязывается к учётной записи, а запрос на её сохранение защищается от подделки межсайтовых запросов.

Показ уведомления выполняется в сервис-воркере и требует правильных обработчиков

Сообщение приходит в фоновый сценарий, и там же показывается уведомление. Есть два вида уведомлений, и разница между ними критична.

Первое создаётся прямо на странице. Оно живёт, пока открыта вкладка, и исчезает вместе с ней. На мобильных устройствах такой способ часто не работает вовсе.

Второе создаётся сервис-воркером и остаётся интерактивным после закрытия страницы. Для мобильных приложений применим только этот вариант.

self.addEventListener("push", (event) => {
  let payload = { title: "Новое сообщение", body: "" };
  if (event.data) {
    try {
      payload = event.data.json();
    } catch (error) {
      payload.body = event.data.text();
    }
  }

  event.waitUntil(
    self.registration.showNotification(payload.title, {
      body: payload.body,
      icon: "/icons/192.png",
      badge: "/icons/badge.png",
      tag: payload.tag,
      data: { url: payload.url || "/" },
      actions: [
        { action: "open", title: "Открыть" },
        { action: "later", title: "Позже" },
      ],
    }),
  );
});

Обратите внимание на обёртку ожидания. Браузер может завершить работу фонового сценария, если обработчик не сообщит, что выполняет асинхронную операцию. Без этой обёртки уведомление иногда не показывается, и воспроизвести проблему бывает непросто.

Метка группировки решает отдельную задачу. Если уведомление с такой же меткой уже висит, новое заменит его. Для обновления статуса заказа это удобно: вместо десяти сообщений пользователь видит одно актуальное.

Обработчик нажатия тоже живёт в сервис-воркере. Он получает данные из уведомления и решает, что делать: открыть страницу, сфокусировать существующую вкладку или выполнить действие в фоне.

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const target = event.notification.data.url;

  if (event.action === "later") {
    return;
  }

  event.waitUntil(
    clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
      for (const client of list) {
        if (client.url.includes(target) && "focus" in client) {
          return client.focus();
        }
      }
      return clients.openWindow(target);
    }),
  );
});

Фокус на существующей вкладке это мелочь, которую замечают все. Если каждое нажатие открывает новую вкладку, через неделю у пользователя их накопится десяток, и он отключит уведомления.

Шифрование содержимого защищает сообщение по пути

Отправка уведомления проходит через стороннюю службу доставки, и содержимое не должно быть ей доступно. Для этого применяется шифрование на стороне сервера.

Схема устроена так. Сервер формирует пару ключей и передаёт открытый ключ клиенту. Клиент при подписке добавляет к нему собственные ключи. Дальше сервер шифрует полезную нагрузку, используя общий секрет, полученный из этих ключей, и отправляет зашифрованный блок.

Практический вывод прост: серверная часть не может быть написана на скорую руку. Обычно применяется готовая библиотека, которая умеет готовить заголовки авторизации и шифровать данные.

import webpush from "web-push";

webpush.setVapidDetails(
  "mailto:Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.",
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY,
);

async function notify(subscription, message) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify(message), {
      TTL: 3600,
    });
  } catch (error) {
    if (error.statusCode === 404 || error.statusCode === 410) {
      await subscriptions.remove(subscription.endpoint);
    }
    throw error;
  }
}

Срок жизни сообщения задаётся явно. Если устройство offline, служба доставки хранит сообщение указанное время. Для статуса заказа хватает часа, для напоминания о встрече сутки, а для одноразового кода минуты.

Второй важный момент в примере это обработка ошибок. Коды четыреста четыре и четыреста десять означают, что подписка больше не существует. Такие записи нужно удалять из базы, иначе список будет расти, а отправка начнёт тормозить.

Практика рассылки требует тишины, частоты и уважения к отказам

Технически работающая рассылка может быть раздражающей. Разница определяется правилами, которые команда принимает сама.

Первое правило касается частоты. Уведомление это вторжение. Отправлять их стоит только тогда, когда сообщение несёт ценность. Пять писем в день о каждом изменении статуса заказа превратят полезный сервис в источник раздражения.

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

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

Четвёртое правило касается группы. Массовая рассылка по всей базе подписок это отдельный инструмент, и применять его стоит с осторожностью. Ошибка в тексте уходит тысячам людей мгновенно, и отозвать её нельзя.

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

Диагностика и учёт показывают реальную картину

Настроенная рассылка нуждается в наблюдении, иначе проблемы обнаруживаются из жалоб.

Первый показатель это доля доставленных сообщений. Разница между отправленными и доставленными указывает на устаревшие подписки или ошибки шифрования.

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

Третий показатель это отписки. Рост после конкретной рассылки прямо указывает на неудачное содержание или чрезмерную частоту.

Четвёртый показатель это отказы в разрешении на первых экранах. Если доля отказов высока, стоит пересмотреть момент запроса и поясняющий текст.

Мобильные уведомления из браузера получились технологией, у которой техническая часть решена надёжно, а вся неопределённость осталась в человеческих решениях. Подписка оформляется десятком строк, отправка через готовую библиотеку, показ в фоновом сценарии. Но ценность определяется тем, когда приложение просит разрешение, что именно пишет и как часто беспокоит. Здесь нет готовой формулы, зато есть простой ориентир: уведомление должно быть желанным. Всё, что этому условию не отвечает, вредит сильнее, чем помогает.