Service workers: estratégias de cache offline-first

1. Fundamentos dos Service Workers e o Modelo Offline-First

Service Workers são scripts JavaScript que atuam como proxies entre o navegador e a rede, permitindo interceptar e gerenciar requisições HTTP. O modelo offline-first prioriza o conteúdo armazenado localmente, garantindo que aplicações funcionem mesmo sem conectividade.

O ciclo de vida do Service Worker possui três eventos principais:

  • Instalação (install): Momento ideal para pré-carregar assets críticos
  • Ativação (activate): Oportunidade para limpar caches antigos
  • Requisições (fetch): Interceptação e decisão sobre como responder

A interceptação de requisições permite implementar diferentes estratégias de cache. As três principais são:

  • Cache-First: Prioriza o cache local, buscando rede apenas quando necessário
  • Network-First: Tenta a rede primeiro, com fallback para cache
  • Stale-While-Revalidate: Serve cache imediatamente e atualiza em segundo plano
// Registro básico de um Service Worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => console.log('SW registrado:', registration.scope))
    .catch(error => console.log('Erro no registro:', error));
}

2. Estratégia Cache-First (Cache Then Network)

A estratégia Cache-First é ideal para recursos estáticos que mudam raramente, como CSS, JavaScript, imagens e fontes. A resposta é servida instantaneamente do cache, reduzindo drasticamente a latência.

// Evento install: pré-cache de assets críticos
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('v1-static').then(cache => {
      return cache.addAll([
        '/',
        '/styles/main.css',
        '/scripts/app.js',
        '/images/logo.png'
      ]);
    })
  );
});

// Estratégia Cache-First
self.addEventListener('fetch', event => {
  if (event.request.url.includes('/static/')) {
    event.respondWith(
      caches.match(event.request).then(cachedResponse => {
        // Retorna do cache ou busca na rede
        return cachedResponse || fetch(event.request).then(response => {
          // Atualiza o cache em segundo plano
          caches.open('v1-static').then(cache => {
            cache.put(event.request, response.clone());
          });
          return response;
        });
      })
    );
  }
});

Casos de uso ideais:
- Assets de frameworks (React, Vue, Angular)
- Folhas de estilo e scripts de bibliotecas
- Imagens e fontes que não mudam frequentemente

3. Estratégia Network-First com Fallback para Cache

Para conteúdo dinâmico que precisa estar sempre atualizado, como páginas de notícias ou dados de API, a estratégia Network-First é mais adequada. A rede é tentada primeiro; se falhar, o cache serve como contingência.

// Estratégia Network-First
self.addEventListener('fetch', event => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          // Atualiza o cache com a resposta da rede
          caches.open('v1-api').then(cache => {
            cache.put(event.request, response.clone());
          });
          return response;
        })
        .catch(() => {
          // Fallback para cache quando a rede falha
          return caches.match(event.request).then(cached => {
            if (cached) return cached;
            // Resposta padrão para quando não há cache nem rede
            return new Response(JSON.stringify({ error: 'Offline' }), {
              status: 503,
              headers: { 'Content-Type': 'application/json' }
            });
          });
        })
    );
  }
});

Aplicações recomendadas:
- Páginas de produtos com preços atualizados
- Feeds de redes sociais
- APIs RESTful com dados mutáveis

4. Stale-While-Revalidate: Equilíbrio entre Velocidade e Frescor

Esta estratégia oferece o melhor dos dois mundos: o usuário vê conteúdo instantaneamente do cache, enquanto o Service Worker atualiza o cache em segundo plano para a próxima visita.

// Estratégia Stale-While-Revalidate
self.addEventListener('fetch', event => {
  if (event.request.url.includes('/posts/')) {
    event.respondWith(
      caches.match(event.request).then(cachedResponse => {
        // Inicia busca na rede em paralelo
        const fetchPromise = fetch(event.request).then(networkResponse => {
          // Atualiza o cache com a resposta da rede
          caches.open('v1-posts').then(cache => {
            cache.put(event.request, networkResponse.clone());
          });
          return networkResponse;
        }).catch(() => cachedResponse);

        // Retorna cache imediatamente ou aguarda rede
        return cachedResponse || fetchPromise;
      })
    );
  }
});

Cenários recomendados:
- Listas de posts de blog
- Dashboards com dados semi-estáticos
- Galerias de imagens com lazy loading

5. Cache de Páginas HTML e Navegação Offline

Para aplicações que precisam funcionar completamente offline, é essencial pré-cachear páginas HTML inteiras durante a instalação.

// Pré-cache de páginas HTML
const CACHE_NAME = 'v1-pages';
const PAGES_TO_CACHE = [
  '/',
  '/about.html',
  '/contact.html',
  '/offline.html'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      return cache.addAll(PAGES_TO_CACHE);
    })
  );
});

// Estratégia App Shell com NavigationPreload
self.addEventListener('activate', event => {
  event.waitUntil(
    self.registration.navigationPreload.enable()
  );
});

self.addEventListener('fetch', event => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      (async () => {
        try {
          const preloadResponse = await event.preloadResponse;
          if (preloadResponse) return preloadResponse;

          const networkResponse = await fetch(event.request);
          return networkResponse;
        } catch (error) {
          const cache = await caches.open(CACHE_NAME);
          const cachedResponse = await cache.match('/offline.html');
          return cachedResponse || new Response('Offline', { status: 503 });
        }
      })()
    );
  }
});

6. Gerenciamento de Cache e Limpeza Inteligente

Manter caches atualizados é crucial para evitar problemas de armazenamento e garantir que usuários recebam conteúdo recente.

// Versionamento e limpeza de caches antigos
const CACHE_VERSION = 'v2';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`;

self.addEventListener('activate', event => {
  const expectedCaches = [STATIC_CACHE, DYNAMIC_CACHE];

  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (!expectedCaches.includes(cacheName)) {
            console.log('Removendo cache antigo:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// Estratégia de expurgo LRU para cache dinâmico
async function limitCacheSize(cacheName, maxItems = 50) {
  const cache = await caches.open(cacheName);
  const keys = await cache.keys();

  if (keys.length > maxItems) {
    // Remove os itens mais antigos
    await cache.delete(keys[0]);
    return limitCacheSize(cacheName, maxItems);
  }
}

Ferramentas de depuração:
- Chrome DevTools > Application > Cache Storage
- Workbox (biblioteca do Google para Service Workers)
- Lighthouse (auditoria de performance PWA)

7. Boas Práticas e Armadilhas Comuns

Evitar erros comuns:

  1. Cache excessivo de APIs mutáveis: Não armazene em cache requisições POST, PUT ou DELETE
  2. Escopo incorreto: O Service Worker só intercepta requisições dentro do seu escopo de path
  3. Ignorar headers de cache: Respeite Cache-Control e ETag do servidor
// Boas práticas: ignorar métodos mutáveis
self.addEventListener('fetch', event => {
  if (event.request.method !== 'GET') return;

  // Respeitar headers de cache do servidor
  if (event.request.headers.get('Cache-Control')?.includes('no-store')) {
    event.respondWith(fetch(event.request));
    return;
  }

  // Aplicar estratégia de cache
  event.respondWith(cacheFirst(event.request));
});

Testes offline:
- Chrome DevTools > Network > Offline
- Simular latência e desconexão
- Testar diferentes estratégias com Workbox

// Exemplo com Workbox (simplificado)
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';

registerRoute(
  /\/api\/posts\//,
  new StaleWhileRevalidate({
    cacheName: 'posts-cache'
  })
);

Referências