🌐 Detecting your location…

Como construir um PWA offline em 2026: Service Workers e IndexedDB Guide

⏱️8 min read  ·  1,605 words

Offline-first significa que o aplicativo funciona a partir de dados locais por padrão e trata a rede como um aprimoramento. Esse é um design diferente de “funciona online, degrada quando offline” e produz um aplicativo que parece instantâneo mesmo com uma boa conexão. Este guia cria um corretamente: cache do service worker, IndexedDB para dados estruturados e sincronização em segundo plano para gravações feitas offline.

A Arquitetura

  1. Trabalhador de serviço intercepta solicitações de rede e atende a partir do cache
  2. Armazenamento em Cache contém o shell do aplicativo — HTML, CSS, JavaScript, fontes
  3. IndexadoDB contém dados estruturados — registra que o usuário lê e escreve
  4. Sincronização em segundo plano reproduz gravações feitas off-line assim que a conexão retorna

A principal mudança mental: a IU lê do IndexedDB, nunca diretamente da rede. Um processo separado mantém o IndexedDB atualizado. Essa única decisão é o que faz o aplicativo funcionar de forma idêntica online e offline.

Etapa 1: O Manifesto

{
  "name": "Field Notes",
  "short_name": "Notes",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a1a2e",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a2e">

O ícone mascarável é importante no Android, onde um ícone não mascarável é cortado em um formato que geralmente corta parte do seu logotipo.

Etapa 2: Registrando o Service Worker

if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const reg = await navigator.serviceWorker.register('/sw.js');

      // Notify the user when a new version is waiting.
      reg.addEventListener('updatefound', () => {
        const newWorker = reg.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            showUpdateBanner(() => {
              newWorker.postMessage({ type: 'SKIP_WAITING' });
            });
          }
        });
      });
    } catch (err) {
      console.error('Service worker registration failed:', err);
    }
  });
}

// Reload once the new worker takes control.
let refreshing = false;
navigator.serviceWorker?.addEventListener('controllerchange', () => {
  if (refreshing) return;
  refreshing = true;
  window.location.reload();
});

Nunca force a ativação de um novo trabalhador sem avisar o usuário. Trocar o código abaixo de um aplicativo em execução no meio da sessão causa falhas confusas – em vez disso, ofereça uma recarga.

Etapa 3: O Service Worker e suas estratégias de cache

Recursos diferentes exigem estratégias diferentes, e usar uma estratégia para tudo é o erro usual.

Estratégia Usar para
Armazenar em cache primeiro Ativos estáticos com hash — eles nunca mudam
Rede primeiro Dados da API onde a atualização é importante
Obsoleto enquanto revalida Conteúdo que pode ser um pouco antigo — avatares, listagens
Somente rede Qualquer coisa com efeitos colaterais
// sw.js
const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE  = `data-${VERSION}`;

const SHELL_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/styles.css',
  '/app.js',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(SHELL_CACHE).then(c => c.addAll(SHELL_ASSETS))
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(
      keys.filter(k => !k.endsWith(VERSION)).map(k => caches.delete(k))
    );
    await self.clients.claim();
  })());
});

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});

self.addEventListener('fetch', (event) => {
  const { request } = event;

  // Never cache anything that changes server state.
  if (request.method !== 'GET') return;

  const url = new URL(request.url);

  // Navigations: network first, fall back to the offline page.
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request).catch(() => caches.match('/offline.html'))
    );
    return;
  }

  // API: network first, fall back to the cached copy.
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Static assets: cache first.
  event.respondWith(cacheFirst(request));
});

async function networkFirst(request) {
  const cache = await caches.open(DATA_CACHE);
  try {
    const response = await fetch(request);
    if (response.ok) cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached;
    return new Response(
      JSON.stringify({ error: 'offline' }),
      { status: 503, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(SHELL_CACHE);
    cache.put(request, response.clone());
  }
  return response;
}

response.clone() é necessário porque um corpo de resposta só pode ser lido uma vez. Armazenar o original em cache e devolvê-lo à página o consome duas vezes e a página recebe um corpo vazio.

Etapa 4: IndexedDB para dados estruturados

O armazenamento em cache contém respostas HTTP inteiras. Para registros que você precisa consultar, classificar e atualizar individualmente, use IndexedDB. A API bruta é detalhada, então vale a pena um wrapper fino.

import { openDB } from 'idb';

const db = await openDB('field-notes', 2, {
  upgrade(db, oldVersion) {
    if (oldVersion < 1) {
      const notes = db.createObjectStore('notes', { keyPath: 'id' });
      notes.createIndex('by-updated', 'updatedAt');
      notes.createIndex('by-synced', 'synced');
    }
    if (oldVersion < 2) {
      db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
    }
  },
});

export async function saveNote(note) {
  const record = { ...note, updatedAt: Date.now(), synced: false };
  await db.put('notes', record);
  await db.add('outbox', { type: 'saveNote', payload: record });
  await requestSync();
  return record;
}

export async function listNotes() {
  return db.getAllFromIndex('notes', 'by-updated');
}

Observe a ordem: primeiro grave no armazenamento local e depois coloque a sincronização na fila. A IU é atualizada instantaneamente e não se importa se a rede está disponível. Esse é o objetivo de priorizar o offline.

Etapa 5: sincronização em segundo plano

A sincronização em segundo plano permite que o navegador reproduza gravações na fila assim que a conectividade retornar, mesmo que a página tenha sido fechada.

// In the page
async function requestSync() {
  const reg = await navigator.serviceWorker.ready;
  if ('sync' in reg) {
    await reg.sync.register('sync-outbox');
  } else {
    await flushOutbox();   // fall back to an immediate attempt
  }
}
// In sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-outbox') {
    event.waitUntil(flushOutbox());
  }
});

async function flushOutbox() {
  const db = await openDB('field-notes', 2);
  const items = await db.getAll('outbox');

  for (const item of items) {
    try {
      const res = await fetch('/api/notes', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item.payload),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      await db.delete('outbox', item.id);
      await db.put('notes', { ...item.payload, synced: true });
    } catch {
      // Leave it queued; the sync event fires again later.
      return;
    }
  }
}

A sincronização em segundo plano não está disponível em todos os navegadores. Sempre mantenha um substituto que libere a caixa de saída quando a página for carregada e quando umonline evento dispara.

Lidando com Conflitos

As gravações off-line criam conflitos e fingir o contrário produz perda silenciosa de dados. Decida uma política explicitamente.

A última gravação vence é mais simples – envie um carimbo de data/hora e deixe o servidor usar o mais novo. Adequado para anotações pessoais, errado para qualquer coisa colaborativa.

Servidor vence em conflito é seguro e requer informar ao usuário que sua alteração off-line foi rejeitada. Nunca descarte-o silenciosamente.

Mesclar é melhor onde a estrutura de dados o suporta, o que geralmente significa um CRDT para documentos genuinamente colaborativos.

const res = await fetch('/api/notes/' + note.id, {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'If-Unmodified-Since': new Date(note.serverUpdatedAt).toUTCString(),
  },
  body: JSON.stringify(note),
});

if (res.status === 412) {
  await queueConflictForReview(note);   // surface it, never drop it
}

Limites de armazenamento

Os navegadores despejam armazenamento sob pressão. Para um aplicativo cujos dados locais são a fonte da verdade até a sincronização, solicite persistência.

if (navigator.storage?.persist) {
  const persisted = await navigator.storage.persist();
  console.log('Persistent storage:', persisted);
}

const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${(usage / 1024 / 1024).toFixed(1)}MB of ${(quota / 1024 / 1024).toFixed(0)}MB`);

Testando offline corretamente

A caixa de seleção offline do DevTools é um ponto de partida, não um teste. Verifique também: recarregar enquanto estiver off-line, uma conexão lenta e instável em vez de uma conexão totalmente ausente, o que acontece quando o service worker atualiza no meio da sessão e se uma gravação na fila sobrevive a uma reinicialização completa do navegador.

# Verify the manifest, service worker, and installability
npx lighthouse https://your-app.com --only-categories=pwa --view

Erros Comuns

Cache de solicitações POST. O armazenamento em cache lida apenas com GET, e o armazenamento em cache de solicitações com efeitos colaterais seria errado de qualquer maneira.

Esquecendoresponse.clone(). A página recebe um corpo vazio.

Nunca invalidando caches antigos. Os usuários obtêm ativos obsoletos indefinidamente. Versione os nomes do cache e exclua os antigos na ativação.

ChamandoskipWaiting() incondicionalmente. Mudanças de código em uma sessão em execução.

Lendo da rede na IU. Leia do IndexedDB e sincronize separadamente, ou o aplicativo não ficará off-line primeiro.

Descartando gravações off-line com falha. A perda silenciosa de dados é o pior resultado possível – sempre surgem conflitos.

Conclusão

O offline primeiro depende de uma decisão arquitetônica:a IU lê e grava no armazenamento local e a sincronização ocorre separadamente em segundo plano. Armazene em cache o shell do aplicativo com um service worker versionado, mantenha os dados estruturados no IndexedDB, enfileire as gravações em uma caixa de saída e reproduza-as com a sincronização em segundo plano e escolha uma política de conflito deliberadamente, em vez de descobrir uma por acidente. Teste com uma conexão instável em vez de uma alternância off-line limpa, porque a conectividade intermitente é o que os usuários realmente experimentam.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work — which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা