SW: stale-while-revalidate на практике + гист

от автора

Service Worker на практике: стратегия stale-while-revalidate (+ готовый гист)

Что делает stale-while-revalidate (SWR)

Идея простая:

  1. Сразу отдать то, что уже лежит в кэше (stale).

  2. Параллельно сходить в сеть за свежей версией (revalidate).

  3. Бесшовно обновить кэш «в фоне», чтобы следующий визит был уже со свежими данными.

Пользователь видит быстрый отклик, а мы — постоянно «подтягиваем» актуальный контент.


Когда применять 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)


ссылка на оригинал статьи https://habr.com/ru/articles/938060/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *