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.
📋 Table of Contents
- A Arquitetura
- Etapa 1: O Manifesto
- Etapa 2: Registrando o Service Worker
- Etapa 3: O Service Worker e suas estratégias de cache
- Etapa 4: IndexedDB para dados estruturados
- Etapa 5: sincronização em segundo plano
- Lidando com Conflitos
- Limites de armazenamento
- Testando offline corretamente
- Erros Comuns
- Conclusão
A Arquitetura
- Trabalhador de serviço intercepta solicitações de rede e atende a partir do cache
- Armazenamento em Cache contém o shell do aplicativo — HTML, CSS, JavaScript, fontes
- IndexadoDB contém dados estruturados — registra que o usuário lê e escreve
- 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.
🔗 Share this article
✍️ Leave a Comment