Service Worker на практике: стратегия stale-while-revalidate (+ готовый гист)
Что делает stale-while-revalidate (SWR)
Идея простая:
Сразу отдать то, что уже лежит в кэше (stale).
Параллельно сходить в сеть за свежей версией (revalidate).
Бесшовно обновить кэш «в фоне», чтобы следующий визит был уже со свежими данными.
Пользователь видит быстрый отклик, а мы — постоянно «подтягиваем» актуальный контент.
Когда применять SWR
Статика: CSS/JS/шрифты/картинки (особенно CDN).
API, не критичное к абсолютной свежести: теги, рейтинги, рекомендации.
Производственные панели — с коротким таймаутом сети (если сеть долго молчит, вернём кэш и не «заморозим» UI).
Где не стоит: HTML-навигации. Для них лучше network-first
c офлайн-фолбэком — иначе можно долго показывать устаревшие страницы.
Регистрация
<!-- register-sw.js -->
<script src="/register-sw.js" defer></script>
// register-sw.js (фрагмент)
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
// Уведомим страницу, что доступно обновление SW
reg.addEventListener('updatefound', () => {
const sw = reg.installing;
sw?.addEventListener('statechange', () => {
if (sw.state === 'installed' && navigator.serviceWorker.controller) {
window.dispatchEvent(new CustomEvent('sw.update.available'));
}
});
});
});
// По клику «Обновить» можно активировать новую версию:
window.activateNewSW = async () => {
const reg = await navigator.serviceWorker.getRegistration();
reg?.waiting?.postMessage('SKIP_WAITING');
};
}
Сам Service Worker
/* sw.js — базовая реализация SWR */
const VERSION = 'v1.0.0';
const STATIC_CACHE = `static-${VERSION}`;
const RUNTIME_CACHE = `runtime-${VERSION}`;
const PRECACHE = ['/', '/offline.html'];
self.addEventListener('install', (e) => {
self.skipWaiting();
e.waitUntil(caches.open(STATIC_CACHE).then((c) => c.addAll(PRECACHE)));
});
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
const keep = new Set([STATIC_CACHE, RUNTIME_CACHE]);
const keys = await caches.keys();
await Promise.all(keys.map((k) => keep.has(k) ? null : caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('message', (e) => {
if (e.data === 'SKIP_WAITING') self.skipWaiting();
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
// HTML-навигации — network-first + офлайн-страница
if (req.mode === 'navigate') {
event.respondWith((async () => {
try {
const fresh = await fetch(req);
(await caches.open(RUNTIME_CACHE)).put(req, fresh.clone());
return fresh;
} catch {
return (await caches.open(STATIC_CACHE)).match('/offline.html');
}
})());
return;
}
// Статика и API — SWR
const isAsset = ['style','script','image','font'].includes(req.destination) ||
url.pathname.match(/\.(css|js|mjs|woff2?|ttf|otf|png|jpe?g|webp|avif|svg)$/i);
const isApi = url.origin === self.location.origin && url.pathname.startsWith('/api/');
if (isAsset || isApi) {
event.respondWith(staleWhileRevalidate(req, RUNTIME_CACHE, {
ignoreSearch: req.destination === 'image',
networkTimeoutMs: isApi ? 2000 : undefined
}));
}
});
async function staleWhileRevalidate(request, cacheName, opts = {}) {
const cache = await caches.open(cacheName);
const cachedPromise = cache.match(request, { ignoreSearch: !!opts.ignoreSearch });
const networkPromise = (async () => {
try {
let controller, signal;
if (opts.networkTimeoutMs) {
controller = new AbortController();
signal = controller.signal;
setTimeout(() => controller.abort(), opts.networkTimeoutMs);
}
const res = await fetch(request, signal ? { signal } : undefined);
if (res && (res.ok || res.type === 'opaque')) cache.put(request, res.clone());
return res;
} catch { return null; }
})();
const cached = await cachedPromise;
if (cached) { networkPromise; return cached; } // мгновенно отдаём кэш
const network = await networkPromise; // иначе ждём сеть
return network || new Response('', { status: 504 });
}
Серверные заголовки для sw.js
# nginx-snippet.conf
location = /sw.js {
add_header Cache-Control "no-store, max-age=0, must-revalidate" always;
}
Отладка и наблюдение
Chrome DevTools → Application → Service Workers: обновление, остановка, симуляция offline.
Network → Disable cache: проверка сетевого пути без влияния HTTP-кэша.
Application → Cache Storage: смотрим, что реально лежит в кэше SW.
Логи: временно добавьте
console.log
в SW (видно в DevTools при открытой вкладке SW).
Частые тонкости и грабли
Opaque-ответы (
no-cors
) кэшируются, но их нельзя читать и валидировать; решайте по политике безопасности проекта.Версионирование кэшей: меняйте
VERSION
при релизе, чистите старые кэши вactivate
.HTML и SWR — осторожно: для страниц лучше
network-first
, чтобы пользователь не «застрял» на старой версии.Квоты хранилища: на мобильных браузерах место ограничено; для картинок добавляйте экспирацию (см. Workbox или IDB-плагин).
Правила кэширования: не кешируйте приватные ответы (личный кабинет) без явной необходимости.
Как добавить «срок годности» без Workbox
Workbox решает задачу элегантно (ExpirationPlugin
), но если нужен ванильный SW, заведите мини-хранилище в IndexedDB (ключ = URL, значение = timestamp) и периодически удаляйте старые записи:
// Псевдокод: после cache.put(request, responseClone)
await idb.set(request.url, Date.now());
// где-то в activate/fetch: пробегитесь по ключам и удалите просроченные
Это 30–40 строк с idb-keyval и подходит для простых правил (maxAgeSeconds
, maxEntries
).
Проверка эффекта: что даст SWR
Время до повторного отображения (повторные визиты) → резко падает.
Нагрузка на бэкенд/CDN → снижается за счёт попадания в кэш SW.
CWV: косвенно помогает LCP/INP на повторных сессиях (быстрее статика).
Полностью рабочий пример + Nginx-сниппет лежит в архиве:
Скачать zip
(файлы:sw.js
,register-sw.js
,offline.html
,nginx-snippet.conf
)