Уяви Service Worker як проксі між твоїм застосунком і мережею: він перехоплює кожен запит і вирішує — віддати з кешу чи звернутись до сервера. Саме це робить офлайн-режим можливим.
Якщо ти вже читав повний гід по PWA, то знаєш що Service Worker — це один з трьох китів Progressive Web App поряд із Web App Manifest та HTTPS. Але що відбувається всередині — як він реєструється, перехоплює запити, зберігає кеш і переживає оновлення — більшість туторіалів пропускає.
У цій статті — технічний розбір від реєстрації до стратегій кешування. З кодом, типовими пастками і поясненням чому деякі речі працюють саме так, а не інакше.
📚 Зміст статті
Що таке Service Worker — і чим він відрізняється від звичайного JS
Звичайний JavaScript — це код, який живе разом із сторінкою. Він маніпулює DOM, реагує на кліки, відправляє запити. Коли сторінка закривається — він зникає разом із нею.
Service Worker — інша річ. Він запускається в окремому потоці (worker thread), повністю ізольованому від основного потоку сторінки (main thread). Це означає три важливих наслідки:
- Немає доступу до DOM. Service Worker не може читати або змінювати HTML-елементи сторінки. Він живе поза нею.
- Може жити після закриття сторінки. Браузер може "будити" Service Worker для фонових задач — синхронізації даних або отримання push-сповіщень — навіть коли жодна вкладка сайту не відкрита.
- Спілкується через postMessage. Якщо Service Worker потрібно передати щось на сторінку або навпаки — вони обмінюються повідомленнями через
postMessage(), як два окремих процеси.
Main thread vs Worker thread — в чому різниця
Main thread — це єдиний потік, де виконується весь JavaScript сторінки, відбувається рендеринг, обробляються події. Якщо щось важке запустити в main thread — сторінка "зависне".
Worker thread — окремий потік, який не блокує UI. Service Worker — це спеціалізований тип Web Worker, заточений під мережеві запити і кешування. На відміну від звичайного Web Worker, він має доступ до Cache API та PushManager і може жити незалежно від сторінки.
Важлива деталь: Service Worker є event-driven. Він не виконується постійно — він "прокидається" у відповідь на певні події (fetch, push, sync) і "засинає" після їх обробки. Це рішення браузера для економії ресурсів.
Де Service Worker "живе" фізично
Service Worker — це окремий JavaScript-файл, зазвичай sw.js, який знаходиться в кореневій директорії сайту або в папці з певним scope. Браузер реєструє його, завантажує і виконує незалежно від основного коду сторінки.
Архітектура виглядає так:
[Сторінка / Main App]
↕ (postMessage)
[Service Worker]
↕ ↕
[Cache API] [Мережа / Сервер]
Коли сторінка робить запит (наприклад, завантажує CSS або звертається до API), браузер спочатку "питає" Service Worker — що робити з цим запитом. Service Worker вирішує: повернути з кешу, піти в мережу або зробити щось комбіноване.
Lifecycle — Register → Install → Activate → Waiting
Lifecycle Service Worker — найбільш плутана тема для тих, хто починає з ним працювати. Розберемо кожен етап і типові помилки на кожному.
Етап 1: Register
Реєстрація відбувається в коді сторінки. Ти кажеш браузеру: "є такий файл sw.js, заберегистрируй його як Service Worker для цього origin".
Якщо браузер вперше бачить цей SW або файл змінився — починається процес встановлення. Якщо файл не змінився (навіть на один байт) — браузер нічого не робить, використовує вже активний SW.
Типова помилка: зареєструвати SW після умови або в блоці try/catch без обробки помилки. Якщо реєстрація впала тихо — ти ніколи не дізнаєшся.
Етап 2: Install
Після реєстрації браузер завантажує файл SW і запускає подію install. Тут зазвичай кешуються критичні ресурси — HTML-оболонка, CSS, JS, іконки.
Типова помилка: не чекати завершення кешування через event.waitUntil(). Якщо не передати проміс у waitUntil — браузер не знатиме коли install закінчився і може перейти до активації до того як кеш наповнений.
Етап 3: Waiting (найпопулярніша пастка)
Після успішного install новий Service Worker не стає активним одразу. Він чекає у стані waiting, поки старий SW звільнить усі відкриті вкладки сайту.
Чому так? Браузер захищає стабільність сесії. Якщо б новий SW активувався поки стара вкладка відкрита — стратегія кешування могла б змінитися посеред сесії користувача. Це могло б зламати роботу застосунку.
На практиці це означає: ти задеплоїв оновлення, воно закешувалось — але користувачі, які тримають вкладку відкритою, продовжують бачити стару версію. На мобільних, де вкладки роками не закриваються, ця ситуація може тривати дуже довго.
Рішення — skipWaiting():
self.addEventListener('install', event => {
self.skipWaiting(); // примусово активуємось, не чекаємо
event.waitUntil(
caches.open('my-app-v2').then(cache => {
return cache.addAll(['/index.html', '/app.css', '/app.js']);
})
);
});
Додай також clients.claim() в activate — щоб вже відкриті вкладки одразу перейшли під контроль нового SW:
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
Етап 4: Active
Коли старий SW звільнив усі вкладки (або ти примусово викликав skipWaiting()) — новий SW стає активним. З цього моменту він перехоплює всі fetch-запити сторінки.
В події activate прийнято чистити старі версії кешу — ті, що залишились від попередніх SW:
const CACHE_NAME = 'my-app-v2';
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== CACHE_NAME) // видаляємо все крім поточної версії
.map(key => caches.delete(key))
)
).then(() => self.clients.claim())
);
});
Важливо: версіонуй назву кешу при кожному деплої. Якщо залишати ту саму назву — старий контент залишиться в кеші після оновлення. Детальніше про цю пастку — у статті 8 критичних помилок при інтеграції PWA.
Реєстрація Service Worker — перший робочий код
Реєстрація відбувається один раз в основному JavaScript-коді сторінки. Ось мінімальний робочий приклад:
// main.js або inline в HTML
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js') // шлях до файлу SW
.then(registration => {
console.log('SW зареєстровано, scope:', registration.scope);
})
.catch(error => {
console.error('Помилка реєстрації SW:', error);
});
});
}
Розберемо кожну частину:
'serviceWorker' in navigator — перевірка підтримки браузером. Якщо браузер не підтримує SW — код просто не виконається, сайт продовжить працювати без офлайн-можливостей.window.addEventListener('load', ...) — чекаємо повного завантаження сторінки перед реєстрацією. Це запобігає конкуренції за ресурси між першим завантаженням і SW..register('/sw.js') — шлях до файлу SW. Зверни увагу: шлях починається з кореня. Scope SW за замовчуванням — директорія, де знаходиться файл sw.js.
Що таке scope і чому він важливий
Scope визначає які URL-запити буде перехоплювати Service Worker. За замовчуванням — це директорія файлу SW.
// sw.js знаходиться в корені — перехоплює ВСІ запити сайту
navigator.serviceWorker.register('/sw.js');
// scope = 'https://example.com/'
// sw.js знаходиться в /app/ — перехоплює тільки /app/*
navigator.serviceWorker.register('/app/sw.js');
// scope = 'https://example.com/app/'
// можна вказати scope явно
navigator.serviceWorker.register('/sw.js', { scope: '/shop/' });
// перехоплює тільки /shop/*
Неправильний scope — одна з причин чому SW "не перехоплює" запити. Якщо файл SW знаходиться в /js/sw.js — він за замовчуванням перехоплює тільки /js/*, а не весь сайт.
HTTPS — обов'язкова умова
Service Worker працює тільки через HTTPS. Це не рекомендація — це технічне обмеження браузера. Без HTTPS navigator.serviceWorker.register() просто не спрацює.
Єдиний виняток — localhost для розробки. На локальному сервері HTTPS не потрібен.
Якщо твій CDN або піддомен для статики працює через HTTP — SW не зможе кешувати ресурси з нього. Детально ця проблема розібрана в статті про 8 типових помилок PWA.
Cache API і Fetch event — як SW перехоплює запити
Чому не localStorage
Перше питання яке виникає: чому для кешування в SW використовується Cache API, а не звичний localStorage?
Відповідь проста: localStorage є синхронним і доступний тільки в main thread. Service Worker живе в окремому потоці і не має до нього доступу. Cache API — це асинхронний, Promise-based інтерфейс, спроєктований саме для worker-контексту.
Cache API також значно більш ємний (сотні мегабайт проти 5–10 МБ у localStorage), підтримує зберігання повних HTTP-відповідей з заголовками і добре підходить для кешування файлів.
Як працює Cache API
// Відкриваємо (або створюємо) кеш з іменем
const cache = await caches.open('my-app-v2');
// Додаємо файли до кешу
await cache.addAll([
'/',
'/index.html',
'/app.css',
'/app.js',
'/icons/icon-192.png'
]);
// Або додаємо один файл
await cache.add('/fonts/main.woff2');
// Отримуємо закешований ресурс
const response = await caches.match('/app.css');
// Видаляємо кеш
await caches.delete('my-app-v1');
Ключ у Cache API — це URL запиту. Кожен ресурс ідентифікується своєю адресою. Тому якщо URL змінився — він вважається новим ресурсом.
Fetch event — серце Service Worker
Коли будь-яка сторінка в scope SW робить мережевий запит — браузер генерує подію fetch у Service Worker. Саме тут відбувається вся логіка: взяти з кешу, піти в мережу, або комбінація.
self.addEventListener('fetch', event => {
// event.request — об'єкт запиту (URL, метод, заголовки)
// event.respondWith() — перехоплюємо запит і повертаємо відповідь
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) {
return cached; // є в кеші — повертаємо одразу
}
return fetch(event.request); // немає — йдемо в мережу
})
);
});
event.respondWith() — це ключовий метод. Він каже браузеру: "не роби звичайний мережевий запит, я сам поверну відповідь". Якщо не викликати respondWith() — браузер піде в мережу як зазвичай.
Важливо: respondWith() потрібно викликати синхронно в обробнику події, але може приймати проміс, який резолвиться пізніше.
Стратегії кешування — яку вибрати для свого проєкту
Не існує єдиної правильної стратегії кешування. Вибір залежить від типу контенту і того, що важливіше: свіжість даних чи швидкість і офлайн-доступ. Ось чотири основні стратегії з кодом:
| Стратегія | Коли використовувати | Свіжість даних |
|---|
| Cache First | Статичні assets (CSS, шрифти, іконки) | Низька — завжди з кешу |
| Network First | API, динамічний контент, HTML-сторінки | Висока — спочатку мережа |
| Stale While Revalidate | Новини, блог, аватари | Середня — з кешу + оновлення фоном |
| Cache Only | Офлайн-fallback, pre-cached ресурси | Тільки кеш, мережа не використовується |
Cache First
Спочатку дивимось у кеш, тільки якщо нема — йдемо в мережу. Ідеально для ресурсів, які рідко змінюються.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
// Зберігаємо нову відповідь у кеш
return caches.open('static-v2').then(cache => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
Network First
Спочатку йдемо в мережу, якщо недоступна — повертаємо з кешу. Ідеально для HTML-сторінок і API де важлива свіжість.
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
if (response.ok) {
const clone = response.clone();
caches.open('dynamic-v2').then(cache => {
cache.put(event.request, clone);
});
}
return response;
})
.catch(() => caches.match(event.request)) // офлайн — з кешу
);
});
Stale While Revalidate
Повертаємо з кешу одразу (швидко!) і паралельно оновлюємо кеш у фоні. Наступний запит отримає вже свіжу версію. Добре для контенту, де невелика затримка оновлення прийнятна.
self.addEventListener('fetch', event => {
event.respondWith(
caches.open('content-v2').then(cache => {
return cache.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
cache.put(event.request, response.clone()); // оновлюємо фоном
return response;
});
return cached || fetchPromise; // повертаємо кеш або чекаємо мережу
});
})
);
});
Cache Only
Тільки кеш, мережа не використовується. Ресурс має бути закешований заздалегідь (наприклад, в install). Використовується для офлайн-fallback сторінок.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
);
});
Як комбінувати стратегії в реальному проєкті
На практиці різні типи ресурсів вимагають різних стратегій. У проєкті Kazki AI використовується такий підхід:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Не перехоплюємо: POST-запити, API, авторизацію
if (event.request.method !== 'GET') return;
if (url.pathname.startsWith('/api/')) return;
if (url.pathname.startsWith('/login') || url.pathname.startsWith('/oauth2')) return;
// Статика (CSS, JS, шрифти, зображення) — Cache First
if (url.pathname.match(/\.(css|js|woff2|png|jpg|svg)$/)) {
event.respondWith(
caches.match(event.request).then(cached =>
cached || fetch(event.request).then(response => {
caches.open('static-v2').then(c => c.put(event.request, response.clone()));
return response;
})
)
);
return;
}
// HTML-сторінки — Network First
event.respondWith(
fetch(event.request)
.then(response => {
if (response.ok) {
caches.open('pages-v2').then(c =>
c.put(event.request, response.clone())
);
}
return response;
})
.catch(() => caches.match(event.request) || caches.match('/offline.html'))
);
});
Коли Service Worker шкодить — і як не переборщити
Service Worker — потужний інструмент, але він легко перетворюється на джерело проблем якщо використовувати його без обмежень. Ось три ситуації де SW шкодить більше ніж допомагає.
Проблема 1: Кешування HTML-сторінок із стратегією Cache First
Це найнебезпечніша помилка. Якщо HTML кешується з Cache First — користувачі назавжди залишаються на старій версії. Нові функції, виправлені баги, змінений контент — нічого цього вони не побачать поки вручну не очистять кеш.
Правило просте: HTML і API — завжди Network First. Статика (CSS, JS з хешем у назві, шрифти, зображення) — Cache First.
Детальний розбір цієї помилки з кодом — у статті 8 критичних помилок при інтеграції PWA.
Проблема 2: Необмежений розмір кешу
Браузер надає обмежений простір для кешу. Якщо кешувати все підряд — рано чи пізно браузер почне автоматично видаляти старі записи, і ти не будеш контролювати що саме зникне.
Обмежуй розмір кешу вручну. Ось простий підхід — видаляти найстаріші записи коли кеш перевищує ліміт:
const MAX_CACHE_SIZE = 50; // максимум 50 файлів у кеші
async function trimCache(cacheName) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > MAX_CACHE_SIZE) {
// видаляємо найстаріші (перші в списку)
await cache.delete(keys[0]);
await trimCache(cacheName); // рекурсивно поки не вкладемось у ліміт
}
}
// Викликаємо після кожного додавання в кеш
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request).then(response => {
caches.open('dynamic-v2').then(async cache => {
await cache.put(event.request, response.clone());
await trimCache('dynamic-v2');
});
return response;
})
);
});
Проблема 3: Кешування авторизованих сторінок і персональних даних
Ніколи не кешуй сторінки де є персональні дані користувача, авторизовані API-відповіді або платіжні форми. Якщо закешувати сторінку профілю — є ризик показати дані одного користувача іншому (наприклад, на спільному пристрої).
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Ці маршрути — повністю поза кешуванням
const skipPaths = [
'/api/', '/login', '/logout', '/register',
'/dashboard', '/profile', '/payment', '/admin'
];
if (skipPaths.some(path => url.pathname.startsWith(path))) {
return; // не викликаємо respondWith — браузер іде в мережу напряму
}
// Решта — обробляємо
event.respondWith(/* ... */);
});
Offline fallback і Background Sync
Offline fallback — /offline.html замість динозавра Chrome
Коли користувач офлайн і запитує сторінку якої немає в кеші — браузер за замовчуванням показує стандартну помилку або знаменитого динозавра Chrome. Це погано з точки зору UX — людина не розуміє що відбувається.
Правильне рішення — показувати власну /offline.html сторінку з повідомленням і можливо кнопкою "Спробувати знову".
Спочатку кешуємо offline.html при install:
const STATIC_ASSETS = [
'/',
'/index.html',
'/offline.html', // обов'язково
'/app.css',
'/app.js'
];
self.addEventListener('install', event => {
self.skipWaiting();
event.waitUntil(
caches.open('static-v2').then(cache => cache.addAll(STATIC_ASSETS))
);
});
Потім повертаємо її як fallback коли мережа недоступна:
self.addEventListener('fetch', event => {
// Тільки для навігаційних запитів (сторінки, не ресурси)
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match('/offline.html');
})
);
return;
}
// Для інших ресурсів — звичайна логіка
});
Так користувач бачить твою branded сторінку з поясненням замість стандартної помилки браузера.
Background Sync — що відбувається з діями офлайн
Background Sync API вирішує важливу проблему: що робити якщо користувач відправив форму або коментар — а в цей момент зникло з'єднання?
Без Background Sync — дані просто втрачаються. З ним — запит ставиться в чергу і автоматично відправляється коли з'єднання відновиться, навіть якщо користувач вже закрив вкладку.
// На сторінці — реєструємо sync тег при спробі відправки
async function submitForm(data) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data)
});
} catch (error) {
// Офлайн — зберігаємо дані і реєструємо sync
await saveToIndexedDB('pending-submissions', data);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('submit-form');
}
}
// У Service Worker — обробляємо коли з'єднання відновилось
self.addEventListener('sync', event => {
if (event.tag === 'submit-form') {
event.waitUntil(
getFromIndexedDB('pending-submissions').then(async items => {
for (const item of items) {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(item)
});
}
await clearIndexedDB('pending-submissions');
})
);
}
});
Підтримка браузерів: Background Sync підтримується в Chrome і Edge. На iOS Safari підтримка з'явилась в Safari 18+ і поки нестабільна. Якщо критична надійність — використовуй як прогресивне покращення поверх звичайного повторного запиту.
Дебаггінг у DevTools — як перевірити що все працює
Service Worker складно дебажити стандартними засобами бо він живе поза сторінкою. Але Chrome DevTools має все необхідне.
Application tab → Service Workers
Відкрий DevTools (F12) → вкладка Application → секція Service Workers.
Тут ти бачиш:
- Status — активний SW (зелена крапка), waiting (жовта), або зупинений.
- Source — посилання на файл sw.js. Клік відкриває його в дебаггері.
- Update on reload — при увімкненні браузер завжди перевіряє нову версію SW при кожному перезавантаженні. Корисно під час розробки.
- Bypass for network — SW тимчасово вимикається, всі запити йдуть напряму в мережу. Зручно щоб перевірити як сайт виглядає без кешу.
- skipWaiting — кнопка для примусової активації нового SW що чекає в Waiting стані.
Перевірка кешу
Application → Cache Storage. Тут видно всі кеші з іменами, список закешованих URL і їх розмір. Можна клікнути на будь-який запис і побачити заголовки відповіді.
Симуляція офлайн-режиму
Application → Service Workers → поставити галочку Offline. Або в Network tab → вибрати No throttling → Offline.
Після цього перезавантаж сторінку — ти побачиш що саме показує твій SW в офлайн-режимі. Чи відображається /offline.html? Чи завантажуються кешовані ресурси?
Примусове оновлення SW під час розробки
Щоб не закривати вкладки при кожній зміні sw.js, увімкни Update on reload в Application → Service Workers. Це змушує браузер вважати SW зміненим при кожному перезавантаженні.
Або можна повністю скинути стан: Application → Storage → Clear site data. Це видаляє всі кеші, IndexedDB, cookies і скасовує реєстрацію SW. Корисно коли щось "зависло" і потрібен чистий старт.
Логування в SW
Логи Service Worker не з'являються в звичайній консолі сторінки. Щоб їх побачити — Application → Service Workers → клік inspect поряд з активним SW. Відкриється окремий DevTools вікно з консоллю SW.
// У sw.js — логуємо для дебагу
self.addEventListener('fetch', event => {
console.log('[SW] Перехоплено запит:', event.request.url);
// ...
});
Якщо Service Worker налаштований — логічний наступний крок це push-сповіщення. Service Worker може отримувати їх від сервера навіть коли браузер закритий. Детальний розбір з кодом для iOS — у статті PWA Push-сповіщення на iOS у 2026: що реально працює.
❓ Часті питання (FAQ)
Чи може Service Worker перехоплювати запити інших сайтів?
Ні. Service Worker обмежений своїм origin (протокол + домен + порт) і scope. Він не може перехоплювати запити до інших доменів або модифікувати запити до зовнішніх API — тільки до ресурсів свого домену в межах заданого scope.
Що відбувається якщо в sw.js є синтаксична помилка?
Service Worker не зареєструється. Браузер спробує виконати файл, отримає помилку і відхилить реєстрацію. Вже активний SW продовжить працювати — новий з помилкою його не замінить. Саме тому важливо завжди обробляти .catch() після register() і перевіряти консоль у DevTools під час розробки.
Скільки Service Workers може бути на одному сайті?
Технічно кілька, якщо вони мають різний scope. Наприклад, /sw-shop.js для /shop/* і /sw-blog.js для /blog/*. На практиці один SW в корені з умовною логікою всередині простіший у підтримці.
Чи потрібен Service Worker для звичайного сайту без PWA?
Не обов'язково, але може бути корисним. Навіть без офлайн-режиму SW дає прискорення повторних завантажень через кешування статики і можливість показати офлайн-сторінку замість помилки браузера. Якщо сайт публічний і важлива швидкість — SW варто розглянути навіть без повноцінного PWA.
Чому Service Worker не оновлюється хоча я змінив sw.js?
Найімовірніше новий SW чекає в Waiting стані — стара версія ще активна. Перевір Application → Service Workers в DevTools: якщо бачиш жовту крапку і статус "waiting" — натисни skipWaiting або закрий всі вкладки сайту. У продакшні вирішується через self.skipWaiting() в події install — як показано в розділі про Lifecycle.
Чи впливає Service Worker на SEO?
Може впливати — як позитивно, так і негативно. Позитивно: прискорює завантаження, покращує Core Web Vitals. Негативно: якщо HTML кешується з Cache First — Googlebot може отримувати застарілий контент. Правило: для HTML-сторінок завжди використовуй Network First, щоб пошуковий робот завжди бачив актуальну версію. Детальніше про вплив PWA на SEO — у повному гіді по PWA.
✅ Висновки
Service Worker — це не магія і не складна технологія, якщо розуміти з чого вона складається. Підсумуємо ключове:
- SW живе в окремому потоці — немає DOM, є Cache API і PushManager. Спілкується зі сторінкою через postMessage.
- Lifecycle має три стадії — Register → Install → Activate. Пастка Waiting вирішується через
skipWaiting() + clients.claim(). - HTTPS — обов'язкова умова — без нього SW просто не реєструється.
- Стратегія кешування залежить від типу контенту — статика Cache First, HTML і API Network First, контентні сторінки Stale While Revalidate.
- Не кешуй все підряд — авторизація, персональні дані і API-відповіді поза кешем. Обмежуй розмір кешу вручну.
- Offline fallback — завжди готуй власну
/offline.html замість стандартної помилки браузера. - DevTools — твій головний інструмент для перевірки стану SW, кешу і симуляції офлайн-режиму.
Якщо ти ще вибираєш між PWA та нативним додатком — детальне порівняння з реальними цифрами і кейсом Kazki AI читай у статті PWA Push-сповіщення на iOS у 2026: що реально працює.
📖 Джерела та корисні посилання
Ключові слова:
service workerофлайн режим веб сайткешування контентуspring boot offlinepwa блогLRU cache Slug: offline-blog-service-worker-implementation ReadTime: 8