Техническая интеграция Web Push — задача, которая кажется простой, пока не столкнёшься с нюансами VAPID, истечением подписки или блокировками браузеров. Мы внедряли этот механизм для 30+ проектов: от интернет-магазинов с миллионной аудиторией до стартапов с десятком пользователей. И каждый раз находили неочевидные грабли — например, Safari на iOS до версии 16.4 просто игнорирует push, а Chrome может принудительно удалять подписки без уведомления. В этом материале разберём реальную реализацию: от генерации VAPID-ключей до серверной отправки на Laravel и мониторинга доставки.
Согласно спецификации Push API (MDN): "Service Worker обязан обрабатывать событие push для отображения уведомления"
Проблемы, которые решаем
Несовместимость браузеров — не единственная сложность. Push-уведомления в браузере живут по строгим правилам: нельзя отправить сообщение без явного разрешения, и оно легко теряется. Вот основные болевые точки:
- Истечение подписки: браузер автоматически удаляет неактивные эндпоинты. Без регулярной чистки вы будете тратить ресурсы на мёртвые подписки — до 30% базы может быть невалидной.
- Персонализация: типовые уведомления без сегментации дают CTR ниже 10%. Сегментация по действиям (брошенная корзина, возврат, достижение) поднимает отклик в три раза — с 8% до 24%.
- Ошибки VAPID: неверный формат ключа или просроченный subject приводят к молчаливому отказу Push Service. Из-за этого теряется до 15% отправок.
Почему VAPID строго обязателен? Это единственный способ аутентификации сервера перед Push Service. Без него запросы просто игнорируются. Генерируем ключи один раз — приватный хранится в .env, публичный передаётся браузеру при подписке.
Как работает архитектура
Ваш сервер → Push Service (Google FCM, Mozilla Autopush) → Браузер → Service Worker → Уведомление
Service Worker — это прокси между сервером и пользователем. Он работает даже когда сайт закрыт, обрабатывает события push и отображает уведомления. Критично правильно реализовать его регистрацию и обработку кликов.
Сравнение каналов коммуникации
| Канал | CTR | Скорость доставки | Персонализация | Стоимость |
|---|---|---|---|---|
| ~20% | Минуты-часы | Высокая | Низкая | |
| SMS | ~30% | Секунды | Средняя | Высокая |
| Web Push | ~60% | Секунды | Высокая | Низкая |
Web Push выигрывает по трём параметрам: дешевле SMS (экономия до $0,02 на сообщение), быстрее Email и персонализируется без потери скорости. Теперь к реализации.
Как сегментация повышает CTR?
Сегментация по событиям продукта даёт прирост CTR в 2-3 раза. Пример: интернет-магазин, внедрив персонализированные push о статусе заказа, увеличил повторные визиты на 30%, а средний чек — на 15%. Используйте теги: для брошенной корзины — cart-abandoned, для новостей — news, для акций — promo. Так вы не перегружаете пользователя и повышаете релевантность.
Что делать при ошибках доставки?
Ошибки Push Service возвращают коды: 410 (подписка удалена), 404 (не найдена), 429 (лимит). В коде на Laravel (см. раздел Сервер) мы автоматически удаляем мёртвые эндпоинты после получения 410. Это снижает количество неудачных отправок на 20%. Если уведомления не доходят, проверьте VAPID-ключи и формат эндпоинта.
Пошаговая инструкция внедрения
- Генерация VAPID-ключей:
npx web-push generate-vapid-keys, сохраните приватный ключ в.env. - Регистрация Service Worker на фронте: в отдельном файле
sw.jsобрабатываем событияpushиnotificationclick. - Подписка браузера: используем
PushManager.subscribe()с VAPID public key. Отправляем объект подписки на сервер. - Серверное хранение: сохраняем эндпоинт,
p256dhиauthтокен в базу (например, PostgreSQL). - Настройка Push API: формируем payload (title, body, icon, url) и отправляем через библиотеку
minishlink/web-push. - Обработка результатов: удаляем эндпоинты, вернувшие ошибку 410 или 404.
Подробнее про VAPID
VAPID (Voluntary Application Server Identification) — стандарт IETF RFC 8292. Приватный ключ подписывает токен, который браузер передаёт Push Service. Если сервер не предоставляет корректного токена, Push Service отклоняет запрос. Ключи можно генерировать через `web-push` или руками, используя Elliptic Curve P-256.Код подписки на TypeScript
// push-subscription.ts const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY; function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); } export async function subscribeToPush(): Promise<boolean> { if (!('PushManager' in window)) { console.warn('Push notifications not supported'); return false; } const permission = await Notification.requestPermission(); if (permission !== 'granted') return false; const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), }); await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription), }); return true; } export async function unsubscribeFromPush(): Promise<void> { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (subscription) { await subscription.unsubscribe(); await fetch('/api/push/unsubscribe', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint }), }); } } Service Worker: обработка push и кликов
// sw.js self.addEventListener('push', event => { const data = event.data?.json() ?? {}; const options = { body: data.body ?? 'Новое уведомление', icon: data.icon ?? '/icons/icon-192.png', badge: '/icons/badge-72.png', image: data.image, tag: data.tag ?? 'default', renotify: data.renotify ?? false, data: { url: data.url ?? '/' }, actions: data.actions ?? [], requireInteraction: data.requireInteraction ?? false, }; event.waitUntil( self.registration.showNotification(data.title ?? 'Уведомление', options) ); }); self.addEventListener('notificationclick', event => { event.notification.close(); const url = event.notification.data?.url ?? '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then(windowClients => { const existing = windowClients.find(c => c.url === url && 'focus' in c); if (existing) return existing.focus(); return clients.openWindow(url); }) ); }); Сервер на Laravel: подписка, отправка и очистка
// Миграция таблицы push_subscriptions Schema::create('push_subscriptions', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('endpoint')->unique(); $table->string('public_key'); $table->string('auth_token'); $table->json('user_agent_data')->nullable(); $table->timestamps(); }); // Контроллер подписки class PushSubscriptionController extends Controller { public function subscribe(Request $request): JsonResponse { $data = $request->validate([ 'endpoint' => 'required|url', 'keys.p256dh' => 'required|string', 'keys.auth' => 'required|string', ]); PushSubscription::updateOrCreate( ['endpoint' => $data['endpoint']], [ 'user_id' => auth()->id(), 'public_key' => $data['keys']['p256dh'], 'auth_token' => $data['keys']['auth'], ] ); return response()->json(['status' => 'ok']); } } // Класс отправки уведомлений use Minishlink\WebPush\WebPush; use Minishlink\WebPush\Subscription; class SendPushNotification { public function send(PushSubscription $sub, array $payload): void { $webPush = new WebPush([ 'VAPID' => [ 'subject' => config('services.vapid.subject'), 'publicKey' => config('services.vapid.public_key'), 'privateKey' => config('services.vapid.private_key'), ], ]); $webPush->queueNotification( Subscription::create([ 'endpoint' => $sub->endpoint, 'contentEncoding' => 'aesgcm', 'keys' => [ 'p256dh' => $sub->public_key, 'auth' => $sub->auth_token, ], ]), json_encode($payload) ); foreach ($webPush->flush() as $report) { if (!$report->isSuccess()) { if ($report->isSubscriptionExpired()) { PushSubscription::where('endpoint', $report->getEndpoint())->delete(); } } } } } Сценарий: уведомления о статусе заказа
Пользователь оформил заказ — система автоматически отправляет push: "Заказ #123: статус изменён на 'Отгружен'". Реализуется через событие OrderStatusChanged. Для этого создаём seeder или слушатель, который при изменении статуса заказа собирает подписки пользователя и отправляет через SendPushNotification.
class OrderStatusChanged { public function handle(Order $order): void { $user = $order->user; $subscriptions = PushSubscription::where('user_id', $user->id)->get(); foreach ($subscriptions as $sub) { $this->sender->send($sub, [ 'title' => 'Статус заказа изменён', 'body' => "Заказ #{$order->number}: {$order->status_label}", 'icon' => '/icons/order-icon.png', 'url' => "/account/orders/{$order->id}", 'tag' => "order-{$order->id}", 'renotify' => true, 'actions' => [ ['action' => 'view', 'title' => 'Посмотреть заказ'], ['action' => 'dismiss', 'title' => 'Закрыть'], ], ]); } } } Что вы получаете
- Полноценный код Service Worker и клиентской подписки на TypeScript.
- Серверную часть на Laravel с миграциями, контроллером и классом отправки.
- Настроенную аналитику доставки и автоматическую очистку мёртвых подписок.
- Документацию: описание API, инструкцию для администратора, доступ к логам.
- Поддержку в течение 2 недель после внедрения.
Этапы и сроки внедрения
| Этап | Длительность | Результат |
|---|---|---|
| Аудит текущей архитектуры | 1 день | План интеграции с учётом стека |
| Настройка VAPID и Service Worker | 0.5 дня | Готовый SW с обработкой push |
| Реализация подписки (фронт+бэк) | 1–2 дня | Рабочая подписка и отписка |
| Настройка сценариев отправки | 1 день | Уведомления по событиям (заказ, регистрация) |
| Тест и мониторинг | 0.5 дня | Отчёт о доставке + логи ошибок |
Сроки: от 1 до 3 дней в зависимости от сложности сценариев.
По статистике, правильно настроенные push-уведомления повышают повторные визиты на 30%, а средний чек — на 15%. Если хотите проверить, как это работает на вашем проекте, пришлите ссылку — мы за один день подготовим оценку. Для консультации свяжитесь с нами.
Для справки: MDN Web Push API — официальная документация.







