Background sync: Sync API, periodic sync, fallback online-first
7 min de leitura
Voce montou o offline (Service Worker + Cache), e dados persistentes (IndexedDB). Mas ha um gap critico: o que acontece quando o usuario faz uma acao que precisa ir pro servidor (POST de comentario, sync de draft, upload de foto) enquanto esta offline? Sem background sync, a acao falha e se perde. O usuario escreveu 500 palavras num comentario, foi pro metro, e perdeu tudo.
Background Sync e' a API que resolve: o SW guarda o request que falhou, e quando o browser detecta que voltou online, re-executa automaticamente. O usuario nunca perde uma acao offline. Twitter, WhatsApp Web, e Gmail offline usam isso.
Se voce entende a Sync API (registrar sync, escutar evento, retry automatico), o Workbox BackgroundSyncPlugin que abstrai 90% do trabalho, e o fallback online-first (manual queue) pra browsers que nao suportam, voce sai de "POST offline = perda de dados" pra "POST offline = replay automatico quando online".
O essencial 🟢
Background Sync em uma frase: o browser detecta quando o usuario volta online, e re-executa requests que falharam offline. Zero intervencao do usuario.
O fluxo classico:
- Usuario faz POST
/api/commentsoffline. - SW intercepta o request, fetch falha (sem rede).
- SW guarda o request em IndexedDB (fila).
- SW registra um sync tag ("sync-comments").
- Usuario volta online.
- Browser dispara
syncevent no SW. - SW le a fila, re-executa os requests.
- Server recebe. Comentario publicado.
- SW limpa a fila.
O usuario nunca viu falha. So publicou quando voltou online.
Suporte de browser. Background Sync
e' Chromium-only (Chrome, Edge, Opera,
Brave) e iOS 16.4+ Safari (limitado). Firefox
ainda nao suporta. Em browsers sem
suporte, o sync event nunca dispara - e
o request fica na fila pra sempre.
Por isso, sempre tenha fallback: use
Workbox BackgroundSyncPlugin (com
fallback de fila manual), ou implemente
fila + retry via online event.
A API: registrar e escutar sync. 2 partes: registrar (do lado da pagina) e escutar (no SW).
// main.tsx (pagina)
async function commentAction(content: string) {
try {
await fetch("/api/comments", {
method: "POST",
body: JSON.stringify({ content }),
});
// Sucesso - comentario publicado
} catch {
// Falhou - registra sync
const reg = await navigator.serviceWorker.ready;
await reg.sync.register("sync-comments");
// Mostra "Comentario sera publicado quando online"
}
}
// sw.js
self.addEventListener("sync", (event) => {
if (event.tag === "sync-comments") {
event.waitUntil(replayFailedRequests());
}
});
async function replayFailedRequests() {
// Re-executa requests da fila
const queue = await getQueueFromIndexedDB();
for (const req of queue) {
try {
await fetch(req);
await removeFromQueue(req);
} catch (err) {
// Continua tentando - SW nao marca sync como completo
throw err; // Browser vai re-tentar mais tarde
}
}
}
event.waitUntil no sync event e' a chave.
Se a promise rejeitar, o browser sabe que
o sync nao terminou e re-tenta mais
tarde (com backoff exponencial: 1min, 5min,
15min, 1h, 6h, 24h). Se a promise resolver,
sync terminou com sucesso.
Workbox BackgroundSyncPlugin - 5 linhas pra tudo. Implementar fila + replay manualmente e' chato. Workbox abstrai:
import { BackgroundSyncPlugin } from "workbox-background-sync";
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
const bgSync = new BackgroundSyncPlugin("comments-queue", {
maxRetentionTime: 24 * 60, // 24 horas em minutos
});
registerRoute(
({ url }) => url.pathname.startsWith("/api/comments"),
new NetworkOnly({
plugins: [bgSync],
}),
"POST"
);
So isso. Workbox:
- Intercepta requests POST
/api/comments. - Se fetch falhar, guarda o request (com body, headers, tudo) em IndexedDB.
- Registra sync automaticamente.
- No
syncevent, re-executa os requests guardados. - Se sucesso, remove da fila.
- Se falha, re-tenta (sync event dispara novamente).
- Apos 24h (maxRetentionTime), descarta.
Por que NetworkOnly? Em cache (SWR, NetworkFirst), o request nao chega online - o SW serve do cache. Pra mutacoes (POST/PUT/DELETE), voce quer sempre ir pra rede. Workbox combina NetworkOnly com BackgroundSync.
maxRetentionTime - por quanto tempo
guardar. O request pode ficar na fila
ate maxRetentionTime minutos (default
7 dias = 10080 min). Apos isso, e'
descartado. Use:
- Minutos (curto): likes, reactions (data importuna apos 1h).
- Horas (medio): comentarios, posts (data importuna apos 24h).
- Dias (longo): uploads, drafts (data importuna apos 7 dias).
online event - o fallback manual. Em
browsers sem Background Sync (Firefox,
alguns mobiles), o Workbox nao dispara
sync automaticamente. Mas o online
event no window funciona em todos
browsers:
// main.tsx (pagina)
window.addEventListener("online", () => {
if ("serviceWorker" in navigator && "SyncManager" in window) {
// Browser com Sync API - SW vai disparar sync
navigator.serviceWorker.ready.then((reg) => {
reg.sync.register("comments-queue");
});
} else {
// Browser sem Sync - dispara fila manualmente
navigator.serviceWorker.controller?.postMessage({
type: "REPLAY_QUEUE",
queue: "comments-queue",
});
}
});
Pattern universo: tentar Sync API
primeiro, cair pra online event +
postMessage como fallback.
Periodic Background Sync - rodar periodicamente. API experimental (Chrome 80+, mas requer permission prompt). Permite o SW rodar em intervalos regulares (ex: sincronizar feed a cada 24h):
// main.tsx
const reg = await navigator.serviceWorker.ready;
const status = await navigator.permissions.query({
name: "periodic-background-sync",
});
if (status.state === "granted") {
try {
await reg.periodicSync.register("sync-feed", {
minInterval: 24 * 60 * 60 * 1000, // 24h
});
} catch (err) {
// Usuario negou ou browser nao suporta
}
}
// sw.js
self.addEventListener("periodicsync", (event) => {
if (event.tag === "sync-feed") {
event.waitUntil(syncFeed());
}
});
Limitacao: o browser decide quando rodar (baseado em bateria, conexao, etc). Nao e' garantia de tempo exato. E requer permission - usuario precisa aceitar. iOS nao suporta.
O queue no IndexedDB - estrutura
tipica. Workbox guarda requests em
IndexedDB (DB workbox-background-sync).
Cada request e' um objeto com:
interface QueuedRequest {
url: string;
method: string;
headers: Record<string, string>;
body?: string | Blob;
timestamp: number; // quando foi enfileirado
metadata?: any; // info custom
}
maxRetentionTime e' por item: items
mais velhos que maxRetentionTime sao
automaticamente descartados.
Background sync + IndexedDB - a combinacao offline-first classica. Pattern:
// 1. Usuario faz POST offline
const comment = { content: "Lorem ipsum" };
await fetch("/api/comments", { method: "POST", body: JSON.stringify(comment) }).catch(
async () => {
// 2. Falhou - salva em IndexedDB local
await db.comments.add({
...comment,
id: `local-${Date.now()}`,
status: "pending",
});
}
);
// 3. Quando voltar online:
// - Workbox BackgroundSync replay → POST real
// - Em paralelo, ouve o sync event e atualiza UI
self.addEventListener("message", (event) => {
if (event.data.type === "COMMENT_SYNCED") {
// Atualizar UI: comment "pending" → "published"
}
});
App mostra comentarios com status "pending sync" ate que o sync event confirme. Usuario ve o estado real.
Aprofundamento 🟡
Workbox BackgroundSyncPlugin com
custom callbacks. O plugin tem hooks
onSync (apos cada replay):
const bgSync = new BackgroundSyncPlugin("queue", {
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request.clone());
await queue.unshiftRequest(entry); // ou delete
} catch (err) {
await queue.unshiftRequest(entry);
throw err;
}
}
// Notificar pagina
self.clients.matchAll().then((clients) => {
clients.forEach((client) =>
client.postMessage({ type: "SYNC_COMPLETE" })
);
});
},
});
onSync libera logica custom: retries
especiais, logging, retry com backoff, etc.
Queue API do Workbox (avancado). Alem
do plugin, voce pode usar a Queue API
diretamente:
import { Queue } from "workbox-background-sync";
const queue = new Queue("my-queue", {
maxRetentionTime: 24 * 60,
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
} catch (err) {
await queue.unshiftRequest(entry);
throw err;
}
}
},
});
// Adicionar request manualmente
queue.pushRequest({ request: new Request("/api/...") });
Util pra fila custom (ex: retry com exponential backoff, fila com prioridade).
Retry com exponential backoff customizado. Default do Workbox: o browser cuida do backoff (1min, 5min, 15min, 1h, 6h, 24h). Pra backoff custom:
let attempts = 0;
const MAX_ATTEMPTS = 5;
const BASE_DELAY = 1000; // 1s
async function replayWithBackoff(req) {
while (attempts < MAX_ATTEMPTS) {
try {
await fetch(req);
return; // sucesso
} catch {
attempts++;
const delay = BASE_DELAY * 2 ** attempts;
await new Promise((r) => setTimeout(r, delay));
}
}
// Max attempts - descarta ou alerta usuario
}
Cuidado: backoff custom pode interferir com sync event. Em prod, use o default do browser e customize so se necessario.
BroadcastChannel entre SW e paginas.
Quando um sync completa, a pagina deve
saber. Alem de postMessage, use
BroadcastChannel:
// sw.js
self.addEventListener("sync", async (event) => {
if (event.tag === "comments-queue") {
event.waitUntil(replayAndNotify());
}
});
async function replayAndNotify() {
// ... replay
const channel = new BroadcastChannel("sync-events");
channel.postMessage({ type: "COMMENTS_SYNCED", count: 3 });
}
// main.tsx (pagina)
const channel = new BroadcastChannel("sync-events");
channel.addEventListener("message", (event) => {
if (event.data.type === "COMMENTS_SYNCED") {
// Atualizar UI
showToast(`${event.data.count} comentarios sincronizados`);
}
});
BroadcastChannel funciona entre multiplas abas (todas ouvem o mesmo canal). Sem polling.
Conflict resolution - server rejeita por outro motivo. E se o servidor ja tem um comment com mesmo ID? Ou se o usuario deletou? O retry pode causar conflitos. Strategies:
- Idempotency keys (header
Idempotency-Key): server dedup requests com mesmo key. - Last-write-wins (timestamp do client vence se mais recente).
- Server-side merge (CRDT ou operational transform).
Background sync + autenticação. O SW faz o request replay, mas o cookie de sessao pode ter expirado durante a offline window. Se o server retorna 401, o request fica na fila pra sempre (loop infinito). Solução:
async function replayWithAuth(req) {
try {
const response = await fetch(req);
if (response.status === 401) {
// Limpa queue, notifica pagina
await queue.delete();
self.clients.matchAll().then((clients) => {
clients.forEach((c) => c.postMessage({ type: "AUTH_EXPIRED" }));
});
return;
}
// ...
} catch { /* network error - retry */ }
}
Detectar 401, parar retry, notificar usuario ("Sua sessao expirou, faca login de novo").
Pra quem quer ir mais alem 🔴
Por que Background Sync e' Chromium-only e a discussao sobre cross-browser. O Chromium implementou Background Sync em 2017. Firefox ainda nao suporta (issue aberta ha 8 anos). Safari adicionou em 2023 mas com limitacoes. Por que: Background Sync requer coordenacao entre browser e OS (decidir quando o SW pode rodar). Chromium tem controle fino; Safari e mais restritivo (battery, privacy). Em 2026, 60-70% dos usuarios estao em browsers com Background Sync. Fallback online event cobre os outros 30-40%.
SyncManager.getTags() - listar tags
registradas. Pra UI que mostra "pending
sync" status:
const reg = await navigator.serviceWorker.ready;
const tags = await reg.sync.getTags();
console.log("Pending syncs:", tags);
// ["comments-queue", "uploads-queue", ...]
Util em apps com multiplas filas (uma por tipo de recurso).
BaaS com offline-first built-in. Se voce nao quer implementar tudo isso, Firebase Realtime Database e Firestore tem offline persistence built-in: writes offline sao enfileirados, sincronizam quando volta online. Supabase tbm (adicionou em 2024). Trade-off: lock-in com vendor. Pra apps que ja usam Firebase, nao reinvente - use o suporte nativo.
register() retorna PermissionStatus.
Em Periodic Sync, o register() pode
falhar por permissao negada ou
rate limit do browser (chrome limita
periodic syncs a 1 por dia pra economizar
bateria). Sempre envolva em try/catch:
try {
await reg.periodicSync.register("sync-feed", { minInterval: 24 * 60 * 60 * 1000 });
} catch (err) {
console.warn("Periodic sync nao disponivel:", err);
// Fallback: dispara sync manual via online event
}
Leitura recomendada:
- web.dev - Background Sync - a doc oficial do Google.
- MDN - SyncManager - referencia MDN.
- Workbox - Background Sync - doc oficial do plugin.
Dica: o erro mais comum em background sync e' tentar usar sem fallback. Firefox e Safari limitado = ate 40% dos usuarios nunca veem o sync rodar. Pattern canonico: Workbox BackgroundSyncPlugin (faz o maximo que o browser suporta) +
onlineevent fallback (cobre o resto). E sempre limpe a queue apos 24h (maxRetentionTime) - senao fica lixo pra sempre. Pattern "fila sem TTL" e' o segundo erro mais comum.
No proximo no, vamos notificacoes push: Web Push API, VAPID keys, subscription management, e o que da pra fazer (notification com acao, badge, vibrate). Cobertura final da stack PWA.
// Quiz
Por que Background Sync precisa de fallback com `online` event?