Класичні односторінкові застосунки (SPA) мають критичну вразливість: вони повністю залежать від безперервного інтернет-з'єднання. При вході в ліфт, підземний перехід, екранований склад або при нестабільному 3G-з'єднанні («Lie-Fi») користувач стикається з білим екраном, нескінченними спінерами, збоями мережевих запитів та втратою незбережених форм.
Архітектура Progressive Web Apps (PWA) переводить вебзастосунок у парадигму Offline-First. У цій моделі локальне сховище та кеш пристрою вважаються первинним джерелом істини, а віддалена мережа — каналом асинхронної синхронізації.
Ключовою ланкою PWA виступає Service Worker. Він перехоплює всі вихідні HTTP-запити застосунку, миттєво віддає закешований каркас інтерфейсу за константний час O(1) і гарантує виконання фонових мутацій за допомогою Background Sync API.
Service Worker працює в ізольованому потоці браузера, не блокує Main Thread, не має прямого доступу до DOM і продовжує функціонувати навіть після закриття всіх вкладок застосунку. Перехоплюючи мережевий стек браузера, він реалізує детерміновані стратегії кешування (Cache-First, Stale-While-Revalidate, Network-First), забезпечуючи миттєвий запуск UI (Time to Interactive менше 50 мс) незалежно від стану фізичної мережі.
Ментально Service Worker можна уявити як локальний прикордонний проксі-сервер (Local Reverse Proxy / Gateway), встановлений безпосередньо на пристрої користувача між JavaScript-кодом React та фізичною мережевою картою:
┌──────────────────────────────────────────────────────────────┐
│ REACT 19 SPA (Main Thread) │
│ fetch('/api/catalog') │ fetch('/assets/bundle.js') │
└──────────────────────────────┬───────────────────────────────┘
│ Перехоплення мережевого стека
▼ (Подія self.onfetch)
┌──────────────────────────────────────────────────────────────┐
│ SERVICE WORKER (Programmable Network Proxy) │
│ │
│ ┌─────────────────────────┐ ┌────────────────────────┐ │
│ │ Workbox Router │ │ Background Sync Queue │ │
│ │ - Cache-First (Static) │ │ (Відкладені мутації │ │
│ │ - Stale-While-Rev (API)│ │ в IndexedDB) │ │
│ └────────────┬────────────┘ └───────────┬────────────┘ │
└───────────────┼──────────────────────────────┼───────────────┘
│ │
┌───────┴────────┐ │
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ Cache Storage │ │ Інтернет │ │ Remote API │
│ (Диск: 0–5 мс)│ │ (Remote CDN) │ │ (При онлайні)│
└───────────────┘ └──────────────┘ └──────────────┘На відміну від стандартного браузерного HTTP-кешу, що підпорядковується виключно серверним заголовкам Cache-Control, Cache Storage повністю підконтрольне коду Service Worker.
Service Worker є строго керованим скінченним автоматом станів (State Machine):
scope).install. У цей момент завантажується та закладається в дисковий кеш мінімальний каркас застосунку (App Shell: HTML, критичні JS/CSS-чанки, шрифти).activate. Воркер здійснює ротацію кешу: видаляє застарілі версії сховища від попередніх деплоїв і перехоплює управління активними вкладками.fetch, при відновленні зв'язку — sync, при серверних нотифікаціях — push.React-клієнт викликає navigator.serviceWorker.register('/sw.js'). Браузер ініціює завантаження файлу та обчислює його Scope.
Подія self.oninstall завантажує App Shell. Виклик self.skipWaiting() форсує перехід воркера в активний стан в обхід очікування.
Подія self.onactivate видаляє ключі старих версій кешів (v1 -> v2). Метод self.clients.claim() захоплює відкриті сторінки.
Слухач self.onfetch маршрутизує запити за стратегіями Workbox, а self.onsync скидає відкладені мутації при появі зв'язку.
Нова версія застосунку була задеплоєна на прод, і файл sw.js оновився. Користувач відкрив три вкладки застосунку. Що станеться з новою версією Service Worker, якщо в події install НЕ було викликано метод self.skipWaiting()?
Співробітники логістичних центрів та кур'єри працюють у бетонних ангарах, підвалах та вантажних ліфтах, де сигнал стільникової мережі періодично зникає. Штрихкоди та накладні скануються локально в IndexedDB, а Service Worker через Background Sync API гарантовано надсилає накопичені мутації на сервер, щойно пристрій ловить Wi-Fi.
В умовах повільного мобільного інтернету (3G / нестабільний 4G) кожна секунда очікування сторінки знижує конверсію на 7–10%. Прекешування критичного CSS, шрифтів і каркаса каталогу з віддачею з Cache Storage за 0–5 мс створює відчуття нативного застосунку, а кошик зберігається навіть при обриві зв'язку.
Користувачі авіарейсів та поїздів метро можуть заздалегідь закешувати статті, подкасти та музичні треки в Cache Storage. Застосунок визначає статус мережі через реактивний хук і перемикає інтерфейс в автономний режим зі списком доступних офлайн-матеріалів.
Корпоративні портали, що використовують стратегію Stale-While-Revalidate для статики та довідників, запускаються миттєво навіть при високій затримці корпоративного VPN-шлюзу (latency понад 300 мс).
Бібліотека Google Workbox є індустріальним стандартом для декларативного опису стратегій маршрутизації мережевих запитів. Існує п'ять базових офлайн-стратегій:
1. Cache-First (Fastest):
Request ──► [Cache Storage] ──(Hit)──► Return (0-5ms)
│
(Miss)
▼
[Network] ──► Save to Cache ──► Return
2. Stale-While-Revalidate (Balanced):
Request ──► [Cache Storage] ──(Hit)──► Return immediately
│
(Паралельний фоновий запит)
▼
[Network] ──► Update Cache in background
3. Network-First (Freshness with Fallback):
Request ──► [Network (timeout: 3s)] ──(Success)──► Update Cache ──► Return
│
(Timeout / Offline)
▼
[Cache Storage] ──(Hit)──► Return stale dataКожен мережевий запит (CSS, JS, fetch API) перехоплюється слухачем self.onfetch. Перевіряються метод і протокол.
Запити до статичної CDN спрямовуються в Cache-First, запити до /api/* — в Stale-While-Revalidate з плагіном ExpirationPlugin.
При падінні мережі та промаху повз кеш для HTML-навігації повертається заздалегідь підготовлена сторінка /offline.html.
Помилкові POST/PUT запити перехоплюються BackgroundSyncPlugin і складаються в чергу IndexedDB до появи зв'язку.
Ви розробляєте PWA мобільного інтернет-магазину. Картки каталогу товарів повинні миттєво відображатися при відкритті застосунку (0–5 мс), але ціни та залишки товарів на складі повинні регулярно актуалізуватися у фоні за наявності інтернету. Яка стратегія Workbox є єдино правильною?
Побудуємо закінчену промислову PWA-інфраструктуру для React 19:
public/sw.js: Повнофункціональний Service Worker з Workbox, стратегіями кешування, Background Sync та обробкою оновлення SKIP_WAITING.src/hooks/useServiceWorkerUpdate.ts: Реактивний хук управління оновленням Service Worker та перехопленням зміни контролера.src/hooks/useOnlineStatus.ts: Хук статусу мережі на базі useSyncExternalStore без помилок гідратації SSR.src/components/pwa/PwaOfflineManager.tsx: Продакшн-компонент із діалогом встановлення PWA, банером виходу в офлайн та сповіщенням про оновлення застосунку.// Service Worker: Fullstack Agentic Lab Enterprise PWA Core v2.4.0
// Використовує Workbox з офіційного CDN Google
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
if (workbox) {
console.log('[SW]: Workbox успішно ініціалізовано.');
// Примусове керування клієнтами
workbox.core.clientsClaim();
// 1. Прекешування критичного каркаса застосунку (App Shell)
const PRECACHE_ASSETS = [
{ url: '/', revision: '2026-v2-core' },
{ url: '/manifest.webmanifest', revision: '2026-v2-manifest' },
{ url: '/offline.html', revision: '2026-v2-offline-page' },
];
workbox.precaching.precacheAndRoute(PRECACHE_ASSETS);
// 2. Стратегія Cache-First для незмінної статики: шрифти, логотипи, скомпільовані CSS/JS
workbox.routing.registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font' ||
request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'agenticlab-static-assets-v2',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 днів
purgeOnQuotaError: true,
}),
],
})
);
// 3. Стратегія Stale-While-Revalidate для REST / GraphQL API запитів
workbox.routing.registerRoute(
({ url, request }) => url.pathname.startsWith('/api/') && request.method === 'GET',
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'agenticlab-api-cache-v2',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200],
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 години
}),
],
})
);
// 4. Стратегія Network-First зі швидким таймаутом для HTML-навігації
const navigationHandler = new workbox.strategies.NetworkFirst({
cacheName: 'agenticlab-pages-cache-v2',
networkTimeoutSeconds: 3,
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200],
}),
],
});
workbox.routing.registerRoute(({ request }) => request.mode === 'navigate', async (params) => {
try {
return await navigationHandler.handle(params);
} catch {
// При повній відсутності мережі та кешу віддаємо офлайн-заглушку
return (await caches.match('/offline.html')) || Response.error();
}
});
// 5. Background Sync: гарантована відправка мутацій при появі зв'язку
const bgSyncPlugin = new workbox.backgroundSync.BackgroundSyncPlugin('offline-mutations-queue', {
maxRetentionTime: 24 * 60, // Черга живе 24 години (у хвилинах)
});
workbox.routing.registerRoute(
({ url, request }) => url.pathname.startsWith('/api/mutations/') && request.method === 'POST',
new workbox.strategies.NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST'
);
// 6. Слухач міжпроцесного повідомлення для негайного оновлення
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
console.log('[SW]: Отримано команду SKIP_WAITING. Активація нового воркера...');
self.skipWaiting();
}
});
} else {
console.error('[SW]: Помилка завантаження скриптів Workbox.');
}'use client';
import * as React from 'react';
/**
* Підписка на нативні браузерні події зміни мережевого статусу
*/
function subscribe(callback: () => void): () => void {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot(): boolean {
return navigator.onLine;
}
function getServerSnapshot(): boolean {
// На сервері завжди припускаємо наявність мережі для запобігання hydration mismatch
return true;
}
/**
* Хук відстеження статусу інтернет-з'єднання
* @returns boolean: true якщо мережа доступна, false якщо пристрій офлайн
*/
export function useOnlineStatus(): boolean {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}'use client';
import * as React from 'react';
interface ServiceWorkerUpdateState {
isUpdateAvailable: boolean;
registration: ServiceWorkerRegistration | null;
updateApp: () => void;
}
/**
* Хук управління життєвим циклом та оновленням Service Worker
*/
export function useServiceWorkerUpdate(): ServiceWorkerUpdateState {
const [isUpdateAvailable, setIsUpdateAvailable] = React.useState(false);
const [registration, setRegistration] = React.useState<ServiceWorkerRegistration | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
let refreshing = false;
// Автоматичне перезавантаження сторінки після зміни контролера
const handleControllerChange = () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
}
};
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {
setRegistration(reg);
// Якщо нова версія вже очікує активації (наприклад, відкрита паралельна вкладка)
if (reg.waiting) {
setIsUpdateAvailable(true);
}
// Слухач виявлення нової версії файлу sw.js
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
// Якщо новий воркер встановився, але старий все ще керує сторінкою
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
setIsUpdateAvailable(true);
}
});
});
})
.catch((err) => {
console.error('[SW Registration Error]:', err);
});
return () => {
navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
};
}, []);
const updateApp = React.useCallback(() => {
if (registration?.waiting) {
// Відправляємо команду SKIP_WAITING в очікуючий воркер
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}, [registration]);
return { isUpdateAvailable, registration, updateApp };
}'use client';
import * as React from 'react';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
import { useServiceWorkerUpdate } from '@/hooks/useServiceWorkerUpdate';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function PwaOfflineManager() {
const isOnline = useOnlineStatus();
const { isUpdateAvailable, updateApp } = useServiceWorkerUpdate();
const [installPrompt, setInstallPrompt] = React.useState<BeforeInstallPromptEvent | null>(null);
const [isStandalone, setIsStandalone] = React.useState(false);
React.useEffect(() => {
// Детекція standalone-режиму (вже встановлено на домашній екран)
const checkStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
setIsStandalone(checkStandalone);
// Перехоплення системного діалогу встановлення
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', () => {
setIsStandalone(true);
setInstallPrompt(null);
});
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
};
}, []);
const handleInstallClick = async () => {
if (!installPrompt) return;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsStandalone(true);
}
setInstallPrompt(null);
};
return (
<div aria-live="polite" className="pointer-events-none fixed inset-x-0 bottom-0 z-50 p-4 flex flex-col gap-3 items-end">
{/* 1. Банер обриву інтернет-з'єднання */}
{!isOnline && (
<div className="pointer-events-auto w-full max-w-md p-3.5 rounded-xl bg-red-950/95 border border-red-800 text-red-100 text-xs flex items-center justify-between shadow-2xl backdrop-blur-md animate-fade-in">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-ping shrink-0" />
<span>Ви в офлайн-режимі. Застосунок працює з локального кешу, мутації зберігаються.</span>
</div>
<span className="font-mono text-[10px] uppercase font-bold text-red-400 bg-red-900/60 px-2 py-0.5 rounded">
Offline
</span>
</div>
)}
{/* 2. Банер доступного оновлення застосунку */}
{isUpdateAvailable && (
<div className="pointer-events-auto w-full max-w-md p-4 rounded-2xl bg-bg-surface border border-accent-violet/40 shadow-2xl space-y-3 animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent-violet/20 border border-accent-violet/40 flex items-center justify-center text-accent-violet font-bold text-sm shrink-0">
⚡
</div>
<div>
<h4 className="text-sm font-bold text-text-primary">Доступне оновлення застосунку</h4>
<p className="text-xs text-text-secondary mt-0.5">
Завантажено нову версію платформи. Оновіть сторінку для застосування змін.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={updateApp}
className="px-3.5 py-1.5 text-xs font-semibold bg-accent-violet text-white rounded-lg hover:bg-accent-violet/90 transition-colors cursor-pointer shadow-md"
>
Оновити зараз
</button>
</div>
</div>
)}
{/* 3. Пропозиція встановлення PWA на робочий стіл */}
{installPrompt && !isStandalone && (
<div className="pointer-events-auto w-full max-w-sm p-4 rounded-2xl bg-bg-surface border border-border shadow-2xl space-y-3 animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-violet/15 border border-accent-violet/30 flex items-center justify-center text-accent-violet font-bold text-base shrink-0">
AL
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-text-primary">Встановити Fullstack Agentic Lab</h4>
<p className="text-xs text-text-secondary mt-0.5">
Швидкий запуск із робочого столу та офлайн-доступ до навчальних матеріалів.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setInstallPrompt(null)}
className="px-3 py-1.5 text-xs text-text-muted hover:text-text-primary transition-colors cursor-pointer"
>
Пізніше
</button>
<button
type="button"
onClick={handleInstallClick}
className="px-3 py-1.5 text-xs font-semibold bg-accent-violet text-white rounded-lg hover:bg-accent-violet/90 transition-colors cursor-pointer"
>
Встановити
</button>
</div>
</div>
)}
</div>
);
}Якщо ваш вебсервер (Nginx, Cloudflare, Vercel) віддає файл sw.js із заголовком Cache-Control: public, max-age=31536000, браузер братиме код воркера зі свого HTTP-кешу і ніколи не дізнається про вихід нової версії застосунку.
Рішення: Для ендпоінта /sw.js сервер зобов'язаний повертати суворі заголовки Cache-Control: no-cache, no-store, must-revalidate.
Наївна реалізація перехоплювача fetch(request).then(res => cache.put(request, res)) зберігає в дисковий кеш відповіді про помилки бекенду. Якщо бекенд короткочасно повернув 500, користувач назавжди залишиться із закешованою помилкою навіть після лагодження сервера.
Рішення: Завжди фільтруйте статус відповіді: кешувати дозволено тільки response.status === 200 (або використовуйте плагін workbox-cacheable-response).
Контекст ServiceWorkerGlobalScope не має доступу до window.localStorage та document. Синхронні операції введення-виведення заблокували б потік перехоплення мережевих пакетів усього браузера.
Рішення: Для зберігання структурованих даних всередині Service Worker використовуйте виключно асинхронні інтерфейси IndexedDB (наприклад, через бібліотеку idb) та Cache Storage.
В умовах слабкого зв'язку («Lie-Fi») стандартний виклик fetch() може висіти до 60–90 секунд, перш ніж браузер викине помилку таймауту. Інтерфейс застосунку весь цей час залишається замороженим на спінері, хоча в локальному кеші лежать робочі дані.
Рішення: Завжди конфігуруйте жорсткий таймаут мережі: networkTimeoutSeconds: 3. Якщо за 3 секунди мережа не відповіла — негайно віддавати дані з кешу.
Файл воркера, розміщений за адресою /static/js/sw.js, за замовчуванням отримує Scope /static/js/. Він фізично не зможе перехоплювати запити до кореня / або API /api/*.
Рішення: Розміщуйте файл sw.js суворо в корені сайту public/sw.js або віддавайте його зі спеціальним HTTP-заголовком Service-Worker-Allowed: /.
self.skipWaiting() та self.clients.claim() забезпечують керований перехід на нову версію без очікування закриття вкладок.Cache-First для незмінної статики, Stale-While-Revalidate для миттєвої віддачі з фоновим оновленням, та Network-First для свіжих критичних даних.useSyncExternalStore синхронізує стан navigator.onLine без попереджень гідратації SSR, а обробник beforeinstallprompt надає брендований UI для встановлення PWA на робочий стіл.