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)

Комментарии (0)