Pular para o conteúdo
React

React: um PWA que funciona offline de verdade

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris tocando um tablet que brilha mesmo desconectado da tomada, com uma coruja sobre um baú de cristais, um castelo com velas flutuantes e uma varinha lançando um escudo de luz sobre a tela

Olá meus Unicórnios! 🦄✨

Sabe quando o metrô entra no túnel e o app que você estava usando vira uma tela branca com um dinossauro? 😅 Pois é. Eu queria o contrário: abrir o site sem internet nenhuma e ele simplesmente aparecer, como se fosse um programa instalado.

Isso tem nome: PWA, ou Progressive Web App. E, ao contrário do que eu achava, não é um framework nem um pacote que você instala. São duas coisinhas que você adiciona a um site React que já existe: um arquivo manifest.json descrevendo o app, e um service worker — um JavaScript que fica entre o seu site e a internet, decidindo o que responder quando a rede não está lá.

Neste artigo eu vou do zero até o site abrindo com o servidor desligado. E vou escrever o service worker à mão, sem plugin. Existe o vite-plugin-pwa, que gera esse arquivo sozinho — mas ele cospe um service worker que ninguém consegue ler, e quando quebra você fica no escuro. São 111 linhas de JavaScript puro; dá para entender cada uma. 🙏

🧩 As duas peças, e o que cada uma faz

Vale separar bem, porque eu confundia as duas no começo:

O manifest é só um cartão de visitas. Ele diz o nome do app, o ícone e a cor — é o que faz o navegador oferecer o botão "Instalar" e o que aparece na tela inicial do celular. Ele não tem nada a ver com funcionar offline.

O service worker é quem faz o trabalho de verdade. Ele guarda cópias dos arquivos e, quando a rede falha, entrega essas cópias. É ele o assunto principal daqui para baixo.

Ou seja: manifest sem service worker dá um app instalável que abre em branco no túnel. Service worker sem manifest dá um site que funciona offline mas não instala. Você quer os dois. 🙂

📇 O manifest.json

Ele vai em public/manifest.json. Tudo que está em public/ o Vite copia para o dist/ sem tocar — é exatamente o que a gente quer aqui.

{
  "id": "/",
  "name": "Bloco de Notas de Exemplo",
  "short_name": "Notas",
  "description": "Um bloco de notas que funciona sem internet.",
  "lang": "pt-BR",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "theme_color": "#1f6f6a",
  "background_color": "#f4efe6",
  "icons": [
    { "src": "/icone-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icone-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icone-mascara-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

Três campos merecem explicação, porque os outros são autoexplicativos:

O start_url é a página que abre quando a pessoa toca no ícone. O scope diz quais URLs pertencem ao app — saiu do escopo, o navegador abre no navegador normal, com barra de endereço e tudo.

O id é o mais sutil e o que quase todo tutorial esquece: ele fixa a identidade do app instalado. Sem ele, o navegador deduz a identidade a partir do start_url — e no dia em que você mudar o start_url, o navegador entende que é outro app. Resultado: quem já tinha instalado fica com dois ícones. 😳

Repare também no ícone com "purpose": "maskable". O Android recorta os ícones num formato próprio (círculo, gota, quadrado arredondado, depende do launcher). Se você entregar só o ícone normal, ele vai ser cortado nas bordas. O maskable é a mesma arte com uma margem de respiro em volta — por isso são arquivos separados, e não o mesmo reaproveitado.

Aí é só apontar para ele no index.html:

<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1f6f6a" />
<link rel="apple-touch-icon" href="/icone-192.png" />

🛠️ O service worker, linha a linha

Este é o coração do artigo. Se você só for ler um pedaço, leia este. 🙏

O arquivo vai em public/sw.js — de novo, para o Vite copiar sem processar. E isso é importante: um service worker não passa pelo bundler. Ele não pode ter import de pacote do node_modules; é JavaScript puro rodando numa thread separada, com as APIs do navegador e mais nada.

Vou mostrar o arquivo inteiro primeiro e depois destrinchar as partes que enganam:

// Service worker escrito a mao. Sem build, sem biblioteca.
// Este arquivo fica em public/, entao o Vite o copia para dist/sw.js sem tocar nele.

// ATENCAO: troque o numero a cada deploy. O nome do cache E a versao do site.
// Se voce nao trocar, o navegador continua servindo os arquivos antigos para sempre.
var NOME_DO_CACHE = "meu-app-v1";

// O que guardar assim que o service worker instala.
// "/" e "/index.html" sao o mesmo arquivo, mas o navegador trata como URLs diferentes.
// ATENCAO: o JS e o CSS do React NAO entram aqui. O Vite poe um hash no nome
// (assets/index-BVBOku8L.js) que muda a cada build — escrever isso na mao quebraria
// no dia seguinte. Eles sao guardados na primeira visita, pelo buscarComCacheDepoisRede.
var ARQUIVOS_ESSENCIAIS = ["/", "/index.html", "/manifest.json", "/icone-192.png"];

async function guardarNoCache() {
  var cache = await caches.open(NOME_DO_CACHE);
  try {
    await cache.addAll(ARQUIVOS_ESSENCIAIS);
  } catch (erro) {
    // addAll e tudo-ou-nada: um unico 404 na lista derruba a instalacao inteira
    // e o site fica sem cache nenhum, sem nenhum aviso na tela.
    console.error("Nao consegui guardar os arquivos essenciais:", erro);
    throw erro;
  }
}

async function limparCachesAntigos() {
  var nomes = await caches.keys();
  for (var i = 0; i < nomes.length; i++) {
    if (nomes[i] !== NOME_DO_CACHE) {
      await caches.delete(nomes[i]);
    }
  }
}

async function buscarComCacheDepoisRede(requisicao) {
  var cache = await caches.open(NOME_DO_CACHE);

  var guardado = await cache.match(requisicao);
  if (guardado) {
    return guardado;
  }

  try {
    var resposta = await fetch(requisicao);
    // Só guarda o que deu certo. Sem este if, um 404 ou um 500 entra no cache
    // e o erro passa a ser servido offline como se fosse a pagina boa.
    if (resposta.ok) {
      await cache.put(requisicao, resposta.clone());
    }
    return resposta;
  } catch (erro) {
    console.error("Sem rede e sem copia no cache:", requisicao.url, erro);
    throw erro;
  }
}

async function responderNavegacao(requisicao) {
  var cache = await caches.open(NOME_DO_CACHE);

  try {
    return await fetch(requisicao);
  } catch (erro) {
    // Offline numa rota do React (/notas/42). Esse arquivo nao existe no servidor:
    // quem desenha a rota e o index.html. Sem esta linha, so a raiz abre offline.
    var indice = await cache.match("/index.html");
    if (indice) {
      return indice;
    }
    throw erro;
  }
}

self.addEventListener("install", function (evento) {
  // Sem o skipWaiting o service worker novo fica parado esperando
  // TODAS as abas do site fecharem. Recarregar a pagina nao basta.
  self.skipWaiting();
  evento.waitUntil(guardarNoCache());
});

self.addEventListener("activate", function (evento) {
  // clients.claim faz este service worker assumir as abas ja abertas agora.
  // Sem ele, ele fica ativo mas sem controlar ninguem ate a proxima visita.
  evento.waitUntil(
    (async function () {
      await limparCachesAntigos();
      await self.clients.claim();
    })(),
  );
});

self.addEventListener("fetch", function (evento) {
  var requisicao = evento.request;

  // Nao mexa em POST, PUT nem DELETE: o Cache Storage so guarda GET.
  if (requisicao.method !== "GET") {
    return;
  }

  // Requisicao de outro dominio (fonte, imagem hospedada fora) sai daqui sem cache.
  if (new URL(requisicao.url).origin !== self.location.origin) {
    return;
  }

  if (requisicao.mode === "navigate") {
    evento.respondWith(responderNavegacao(requisicao));
    return;
  }

  evento.respondWith(buscarComCacheDepoisRede(requisicao));
});

📦 O erro que me custou a tarde: o React não estava no cache

Olha só a lista de arquivos essenciais. Na minha primeira versão ela era assim, e me parecia completa:

var ARQUIVOS_ESSENCIAIS = ["/", "/index.html", "/manifest.json", "/icone-192.png"];

Instalei, desliguei o servidor, recarreguei — e a página abriu completamente em branco. Sem erro na tela, sem mensagem, nada. O index.html vinha do cache lindamente… e o React nunca aparecia.

O motivo estava no HTML gerado pelo build:

<script type="module" crossorigin src="/assets/index-BVBOku8L.js"></script>

Está vendo aquele BVBOku8L? É um hash que o Vite coloca no nome do arquivo e que muda a cada build. Eu não tinha colocado o JS na lista porque, honestamente, não teria como: escrever esse nome à mão hoje significa que amanhã, depois de um npm run build, o service worker estaria guardando um arquivo que não existe mais. 😅

É por isso que a lista de essenciais tem só o que tem nome fixo, e o JS e o CSS entram no cache de outro jeito: na primeira vez que o navegador os pede, pelo buscarComCacheDepoisRede. Ele busca na rede, devolve para a página e guarda uma cópia no caminho. Da segunda visita em diante, já está tudo lá.

🎭 A primeira visita não é controlada — e isso confunde muito

Essa é a parte que mais me fez coçar a cabeça, e quase ninguém avisa. 🤯

Quando alguém entra no seu site pela primeira vez, o navegador baixa o HTML e o JavaScript antes de o service worker existir. Ele só é registrado depois que o seu código React roda. Ou seja: naquela primeira visita, o service worker não viu nenhuma daquelas requisições — elas aconteceram antes de ele assumir o controle.

Na prática isso quer dizer que o bundle do React só entra no cache a partir do segundo carregamento. Se você testar assim — abre o site, desliga o servidor, recarrega — vai ver a tela branca e achar que tudo está quebrado. Não está: falta um recarregamento.

É exatamente para encurtar essa janela que existem as duas linhas que eu vou explicar agora.

⚡ skipWaiting e clients.claim: quando a atualização entra

Estas duas são famosas por serem coladas de tutoriais sem ninguém saber o que fazem. Vamos lá:

self.addEventListener("install", function (evento) {
  // Sem o skipWaiting o service worker novo fica parado esperando
  // TODAS as abas do site fecharem. Recarregar a pagina nao basta.
  self.skipWaiting();
  evento.waitUntil(guardarNoCache());
});

self.addEventListener("activate", function (evento) {
  // clients.claim faz este service worker assumir as abas ja abertas agora.
  // Sem ele, ele fica ativo mas sem controlar ninguem ate a proxima visita.
  evento.waitUntil(
    (async function () {
      await limparCachesAntigos();
      await self.clients.claim();
    })(),
  );
});

O comportamento padrão do navegador é conservador: quando você publica um service worker novo, ele é baixado e fica parado, esperando. Ele só assume quando todas as abas do seu site forem fechadas. E olha o detalhe cruel: recarregar a página não conta — a aba continua sendo a mesma. Você pode dar F5 vinte vezes e continuar rodando a versão velha. 😤

O skipWaiting() corta essa fila: o service worker novo ativa na hora.

Só que ativar não é o mesmo que controlar. Um service worker recém-ativado ainda não manda nas abas que já estavam abertas — elas seguem sem controlador até a próxima visita. O clients.claim() resolve isso: ele assume as abas abertas imediatamente.

Os dois juntos são o que faz o cache se encher já no segundo carregamento, em vez de no terceiro. E são o que torna o teste do parágrafo anterior previsível.

🗺️ O escopo: o erro clássico que mata tudo em silêncio

Se tem uma coisa que vale gravar, é esta: o service worker só controla o que está na pasta dele ou abaixo dela.

Parece detalhe. Não é. Se você, por organização, guardar o arquivo em public/js/sw.js — porque é onde os JavaScripts do projeto moram, faz todo sentido —, ele passa a controlar só o que está dentro de /js/. A raiz do site, que é justamente o que você quer que funcione offline, fica de fora.

E o pior: nada dá erro. O registro é bem-sucedido, o console mostra a mensagem de sucesso, e o site simplesmente não funciona offline.

Eu registrei o mesmo arquivo nos dois lugares para comparar o escopo que cada um recebe:

registrado "/sw.js"  ->  escopo http://127.0.0.1:5198/
   controla a raiz do site? SIM
registrado "/js/sw.js"  ->  escopo http://127.0.0.1:5198/js/
   controla a raiz do site? NAO

Por isso o arquivo fica em public/sw.js, e não numa subpasta. É feio? Um pouquinho. É o que funciona? É. 😅

export async function registrarServiceWorker() {
  // Navegador antigo simplesmente nao tem a API. Sem este if, o site quebra nele.
  if (!("serviceWorker" in navigator)) {
    console.warn("Este navegador nao suporta service worker.");
    return;
  }

  try {
    // O caminho importa: "/sw.js" controla o site inteiro.
    // Se o arquivo estivesse em "/js/sw.js", ele so controlaria o que esta dentro de /js/.
    const registro = await navigator.serviceWorker.register("/sw.js");
    console.log("Service worker registrado. Escopo:", registro.scope);
  } catch (erro) {
    console.error("Falhou ao registrar o service worker:", erro);
  }
}

E o registro é chamado no main.jsx, depois de a aplicação subir:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import { registrarServiceWorker } from "./registrarSW.js";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

registrarServiceWorker();

🧭 A rota do React que some offline

Esta armadilha é específica de quem usa React Router, e é fácil de não perceber porque a home continua funcionando.

Pense na rota /notas/42. Quando você navega até ela clicando dentro do app, quem desenha a tela é o React, no navegador — nenhuma requisição sai. Mas quando a pessoa recarrega a página estando em /notas/42, o navegador pede esse endereço ao servidor. E esse arquivo não existe: no seu servidor há uma regra mandando qualquer rota desconhecida devolver o index.html, e é o React que se vira dali.

Offline, não há servidor para aplicar essa regra. Por isso o service worker precisa fazer o mesmo papel:

async function responderNavegacao(requisicao) {
  var cache = await caches.open(NOME_DO_CACHE);

  try {
    return await fetch(requisicao);
  } catch (erro) {
    // Offline numa rota do React (/notas/42). Esse arquivo nao existe no servidor:
    // quem desenha a rota e o index.html. Sem esta linha, so a raiz abre offline.
    var indice = await cache.match("/index.html");
    if (indice) {
      return indice;
    }
    throw erro;
  }
}

O requisicao.mode === "navigate" é como se identifica "a pessoa está carregando uma página", em oposição a "a página está buscando um script ou uma imagem". Sem esse tratamento, só a raiz abre offline — e qualquer link direto para uma rota interna quebra.

Repare que aqui eu tento a rede primeiro e só caio no cache quando ela falha. É o inverso do que faço com os arquivos estáticos. O motivo: HTML é o que mais muda, então quando há internet eu quero sempre a versão fresca do servidor.

🕰️ O cache que serve a versão velha para sempre

Agora a armadilha mais perigosa de todas, porque ela não aparece no seu computador — aparece na máquina de quem já visitou o site. 😨

Um arquivo que está no cache é servido do cache. Ponto. Você pode publicar uma correção urgente no servidor: quem já tem a versão antiga guardada continua vendo a antiga. Para sempre.

Eu montei essa situação de propósito para ver acontecer. O servidor entrega um texto, o navegador lê uma vez, o servidor passa a entregar um conteúdo diferente — e eu leio de novo:

1. primeira leitura, com o cache vazio : VERSAO 1
2. servidor ja publicou a VERSAO 2, o navegador le: VERSAO 1
3. depois de trocar o nome do cache, o navegador le: VERSAO 2

Olha a linha 2. O servidor já estava publicando a VERSAO 2, e o navegador continuou lendo a VERSAO 1. É esse o estrago.

A solução é a linha mais importante do arquivo inteiro, e é uma linha boba:

// ATENCAO: troque o numero a cada deploy. O nome do cache E a versao do site.
// Se voce nao trocar, o navegador continua servindo os arquivos antigos para sempre.
var NOME_DO_CACHE = "meu-app-v1";

O nome do cache é a versão do seu site. Trocou de meu-app-v1 para meu-app-v2? O caches.open abre um cache novo, vazio, e tudo é buscado da rede de novo. É por isso que o limparCachesAntigos roda no activate: ele apaga todo cache cujo nome não seja o atual, senão a pessoa acumularia uma cópia de cada versão que já visitou, ocupando disco à toa.

Na prática: trocar esse número faz parte do deploy. Esqueceu, publicou a versão velha para todo mundo que já tinha visitado. 🙃

🔌 A prova: o servidor desligado

Chega de teoria. Eu subi o dist/ num servidor local, deixei o navegador registrar o service worker, desliguei o servidor e recarreguei a página.

Captura de tela do aplicativo de notas aberto no navegador com o servidor desligado, mostrando a lista de notas e um painel escuro com o nome do cache e os arquivos guardados

A lista de notas está lá, com o servidor fora do ar. E o painel escuro mostra o porquê: o cache meu-app-v1 guardou a raiz, o index.html, o manifest.json, o ícone — e o /assets/index-BVBOku8L.js, o bundle do React que entrou pelo caminho da primeira busca, não pela lista de essenciais.

A saída do teste, com as três etapas:

--- 1. primeira visita, com rede ---
  [navegador] Service worker registrado. Escopo: http://127.0.0.1:5199/
titulo na tela: Minhas notas
nome do cache: meu-app-v1
guardado no cache: / /assets/index-BVBOku8L.js /icone-192.png /index.html /manifest.json

--- 2. servidor desligado (offline de verdade) ---
servidor fechado.
titulo na tela apos recarregar sem servidor: Minhas notas

--- 3. rota do React offline (/notas/42) ---
titulo em /notas/42 sem servidor: Minhas notas

A etapa 3 é a que me deixou mais feliz: /notas/42 é uma rota que nunca existiu como arquivo em lugar nenhum, e ela abre sem servidor. É o responderNavegacao devolvendo o index.html guardado e o React montando a tela a partir dali.

🧯 Como não enlouquecer testando

Duas coisas que aprendi na marra e que economizam muito tempo:

Service worker não roda no npm run dev — ou melhor, roda e atrapalha. O servidor de desenvolvimento tem recarregamento automático, e o service worker guardando arquivos no meio disso gera confusão sem fim. Teste sempre no build de verdade: npm run build e depois sirva a pasta dist/.

Use uma aba anônima para cada teste. O service worker é teimoso por natureza — é literalmente o trabalho dele. Uma aba anônima começa sem cache e sem registro, então o que você vê é o que uma pessoa nova veria. No painel do navegador (Application → Service Workers) existe também um Unregister e um Clear storage, que resolvem quando você quer voltar do zero.

E se algo estiver estranho, a primeira pergunta é sempre a mesma: o service worker está controlando esta página? Uma linha no console responde:

navigator.serviceWorker.controller

Se isso devolver null, ele não está no comando — e nenhum fetch seu está passando por ele. Provavelmente é a primeira visita, ou é o escopo errado. 🔍

🎁 O resumo do que realmente importa

Se eu pudesse mandar um bilhete para mim mesma antes de começar, seria este:

O manifest.json instala, o sw.js é quem faz funcionar offline. O arquivo do service worker fica na raiz, senão ele controla só a subpasta e nada avisa. O JS do React tem hash no nome, então ele não entra na lista fixa — entra no cache quando é pedido pela primeira vez. A primeira visita não é controlada, e é por isso que skipWaiting e clients.claim existem. Requisição de navegação cai no index.html, senão as rotas internas somem offline. E o nome do cache é a versão do site: trocar é parte do deploy.

São 111 linhas de JavaScript, zero dependências e nenhum passo de build. Dá para ler tudo numa sentada, e é exatamente por isso que eu prefiro escrever à mão a deixar um plugin gerar. Quando quebrar — e uma hora quebra —, você vai saber onde olhar. 💪

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também

JavaScript

Testando câmera e microfone no navegador

Como pedir permissão, mostrar a prévia, gravar e reproduzir áudio e vídeo direto do navegador, com JavaScript puro e sem nenhuma biblioteca.