Envio de Formulários Offline com Background Sync
Coloque envios de formulários no IndexedDB, reenvie com Background Sync e fallback online, e evite pedidos duplicados com Idempotency-Key.
Para garantir que o envio de um formulário sobreviva a uma conexão instável ou ausente, grave a submissão no IndexedDB antes de qualquer chamada de rede, registre uma tag de Background Sync e deixe o service worker reenviar a requisição enfileirada a partir do seu evento sync quando a conectividade for restaurada — e adicione um fallback com o evento online para os navegadores que não suportam Background Sync. Essa última parte não é opcional: a API de Background Sync está disponível apenas em navegadores baseados em Chromium, portanto a fila no IndexedDB combinada com um listener do evento online é a base que funciona em todos os ambientes, e o Background Sync é a melhoria adicionada sobre ela.
Este artigo conduz um exemplo completo — um formulário de contato/pedido — por todo o padrão: fila durável, registro de sincronização, replay no service worker, o fallback universal, prevenção de envios duplicados e o problema de confirmação sobre o qual ninguém avisa. O artigo pressupõe que você já registrou um service worker e está familiarizado com promises e fetch. O cache genérico (cache-first, network-first) é um pré-requisito, não o tema aqui — consulte as estratégias de cache do MDN e os módulos de estratégia do Workbox para isso.
Principais Conclusões
- Grave a submissão no IndexedDB com um ID de requisição gerado pelo cliente, um timestamp
queuedAte um contador de tentativas antes da chamada de rede, para que um POST interrompido já seja durável em vez de perdido. - O Background Sync pontual (
SyncManager) é suportado apenas em navegadores Chromium — Chrome, Edge, Opera e Samsung Internet — e está ausente no Firefox, Safari e iOS, razão pela qual um fallback com o eventoonlineé obrigatório em 2026. - No handler
syncdo service worker, faça o POST de cada item enfileirado, remova-o em caso de sucesso e lance uma exceção em caso de falha — lançar a exceção instrui o navegador a manter o registro e tentar novamente com backoff exponencial gerenciado pelo navegador. - Envie o ID da requisição como um header
Idempotency-Keypara que uma submissão já aceita pelo servidor seja deduplicada quando o replay chegar, evitando pedidos duplicados. - O
BackgroundSyncPlugindo Workbox enfileira e reenvia automaticamente, mas tenta novamente apenas em caso de falhas de rede genuínas — uma resposta 4xx ou 5xx é tratada como entregue e não será reenviada.
Por que um fetch simples no envio falha offline
Um fetch simples no envio do formulário falha silenciosamente quando o dispositivo está offline: a promise é rejeitada, a requisição nunca chega ao servidor e, a menos que você tenha implementado um tratamento explícito, o usuário não recebe nenhum sinal de que algo deu errado. Os service workers resolvem o cache de assets, mas não fazem o retry automático das novas requisições de dados que um formulário realiza — um POST com falha simplesmente desaparece.
Um POST offline com falha silenciosa é o clássico bug do tipo “o usuário acredita que teve sucesso; os dados nunca chegaram”. Ele também é invisível para o monitoramento do backend, pois a requisição nunca chegou ao servidor — não há nada para registrar em log. O session replay é a técnica que fecha essa lacuna de visibilidade: um replay reconstrói a realidade do lado do cliente — o toque em Enviar, a tela otimista de “Obrigado!”, a navegação para outra página — que você pode correlacionar com a eventual aparição de um registro no servidor. Essa correlação é exatamente como se detecta a lacuna de confiança que todo esse padrão existe para prevenir.
“Enviar e esquecer” deve parecer instantâneo para o usuário e durável para você: a submissão é capturada localmente no momento em que ele toca em Enviar, e a entrega é um problema do sistema, não dele.
Passo 1: Enfileirar a submissão no IndexedDB antes da chamada de rede
Discover how at OpenReplay.com.
Persista a submissão no IndexedDB primeiro e, em seguida, tente a entrega. Armazenar antes de qualquer chamada de rede significa que um POST com falha, interrompido ou offline já é durável — você reenvia a partir do store, nunca a partir do estado volátil da página. Adicione três campos a cada item enfileirado: um ID de requisição gerado pelo cliente (para deduplicação posterior), um timestamp queuedAt e um retryCount.
// db.js — a thin promise wrapper around IndexedDB
const DB_NAME = 'outbox';
const STORE = 'submissions';
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function enqueue(payload) {
const db = await openDB();
const item = {
id: crypto.randomUUID(), // request ID for idempotency
payload,
queuedAt: Date.now(),
retryCount: 0,
};
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(item);
tx.oncomplete = () => resolve(item);
tx.onerror = () => reject(tx.error);
});
}
crypto.randomUUID() está disponível em todos os navegadores modernos e no escopo do service worker, conforme a referência do Crypto.randomUUID() no MDN. O handler do formulário chama enqueue, exibe um estado otimista de “Enfileirado” e, em seguida, solicita uma sincronização.
Passo 2: Registrar o Background Sync e tratar o evento sync
O Background Sync permite registrar uma tag nomeada a partir da página; o navegador dispara um evento sync no seu service worker quando julga que a conectividade foi restaurada, e pode entregar esse evento mesmo após o usuário ter navegado para outra página ou fechado a aba. Esse comportamento de entrega diferida pós-navegação é o que o torna mais robusto do que um listener do evento online, que só dispara enquanto a página está aberta.
Faça a detecção de recursos para serviceWorker e SyncManager antes de depender deles, e aplique o fallback imediatamente se algum deles estiver ausente:
// form handler, after enqueue()
async function requestSync() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
try {
await reg.sync.register('sync-forms');
return;
} catch {
// registration failed — fall through to the fallback
}
}
flushQueue(); // the everywhere fallback (Step 3)
}
A chamada register('sync-forms') e a interface SyncManager estão documentadas na API de Background Synchronization do MDN. No service worker, faça a correspondência com a tag, envolva o trabalho em event.waitUntil() para manter o worker ativo, faça o POST de cada item, remova-o em caso de sucesso e lance uma exceção em caso de falha para que o navegador mantenha o registro e tente novamente:
// service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-forms') {
event.waitUntil(replayQueue(event));
}
});
async function replayQueue(event) {
const items = await getAll(); // read from IndexedDB
for (const item of items) {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id, // dedupe on the server
},
body: JSON.stringify(item.payload),
});
if (res.ok) {
await remove(item.id); // delete on success
} else if (res.status >= 500) {
if (event.lastChance) await notifyFailure(item);
throw new Error('server error, retry'); // keep the registration
}
// 4xx: the payload is bad — don't retry blindly; surface it instead
}
}
Lançar uma exceção dentro de waitUntil é o sinal que mantém a sincronização pendente. Os navegadores que suportam a API reenviam as requisições com falha de forma autônoma em um intervalo gerenciado pelo próprio navegador, provavelmente utilizando backoff exponencial entre as tentativas de replay. O número de tentativas e o intervalo exato são gerenciados pelo navegador e não estão documentados contratualmente, portanto não codifique suposições como “três tentativas”. Em vez disso, verifique event.lastChance — documentado na referência do SyncEvent no MDN — para detectar a última tentativa e informar ao usuário que a submissão falhou definitivamente, em vez de deixá-la desaparecer silenciosamente.
Passo 3: O fallback que funciona em todos os navegadores
Como o Background Sync é exclusivo do Chromium, o fallback com o evento online não é uma nota de rodapé — é a base que todos os navegadores executam. Dois gatilhos cobrem o caminho para navegadores não Chromium: reenvie a fila sempre que o evento online for disparado e faça o flush em cada carregamento de página para capturar submissões enfileiradas em uma sessão anterior.
// runs on the page, everywhere
export async function flushQueue() {
if (!navigator.onLine) return;
const items = await getAll();
for (const item of items) {
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id,
},
body: JSON.stringify(item.payload),
});
if (res.ok) await remove(item.id);
} catch {
await bumpRetryCount(item.id); // stays queued for the next trigger
}
}
}
window.addEventListener('online', flushQueue);
window.addEventListener('load', flushQueue);
A limitação honesta do fallback: ele só é executado enquanto uma página da sua origem está aberta, pois depende de eventos no nível da página. Ele não consegue ativar uma aba fechada da forma que o Background Sync consegue. Esse é o tradeoff preciso — alcance universal, garantias de entrega mais fracas — e é por isso que você registra o Background Sync primeiro e aplica o fallback depois.
Suporte ao Background Sync nos navegadores em 2026
Em junho de 2026, o Background Sync pontual (SyncManager) é uma API exclusiva do Chromium. O MDN o classifica como “disponibilidade limitada” — não como Baseline, pois não funciona em alguns dos navegadores mais amplamente utilizados. Os dados do caniuse confirmam a divisão abaixo.
| Navegador | Background Sync pontual (SyncManager) |
|---|---|
| Chrome | ✅ Suportado |
| Edge (Chromium) | ✅ Suportado |
| Opera | ✅ Suportado |
| Samsung Internet | ✅ Suportado |
| Firefox | ❌ Não suportado |
| Safari (macOS) | ❌ Não suportado |
| Safari (iOS) | ❌ Não suportado |
| Android WebView | ❌ Não suportado |
Dois pontos importantes merecem atenção. O Microsoft Edge passou a suportar a API somente após migrar para o Chromium, portanto qualquer afirmação de 2019 de que “o Edge não suporta” está desatualizada. E o Android WebView — o componente que aplicativos nativos incorporam para navegação in-app — não expõe o SyncManager, de modo que encapsular uma PWA em um shell WebView elimina a fila de sincronização nativa e recai sobre o caminho do evento online. Note que esta é a API de Background Sync pontual; o Periodic Background Sync é uma API separada e experimental para atualizações recorrentes — não confunda as duas.
Idempotência: evitando pedidos duplicados nos replays
Qualquer POST reenviado corre o risco de gerar um duplicado: a primeira tentativa pode ter chegado ao servidor e sido confirmada antes de a conexão cair, de modo que a resposta nunca retornou e seu cliente reenvia uma requisição que o servidor já processou. Previna isso com um ID de requisição gerado pelo cliente enviado como header Idempotency-Key — o mesmo item.id armazenado no momento do enfileiramento. O servidor usa esse ID como chave e retorna o resultado original para uma requisição repetida, em vez de criar um segundo registro.
Idempotency-Key é uma convenção de API consolidada, popularizada pelo Stripe e pelo PayPal, e é objeto de um Internet-Draft do IETF, draft-ietf-httpapi-idempotency-key-header. Trate-o como uma convenção em desenvolvimento, não como um padrão ratificado: o campo de header de requisição HTTP Idempotency-Key pode ser usado para tornar métodos HTTP não idempotentes, como POST ou PATCH, tolerantes a falhas, mas o próprio draft traz o aviso padrão de que não deve ser citado de outra forma que não como trabalho em andamento. O lado do servidor é direto: para uma requisição duplicada cujo idempotency key já foi visto, o servidor de recursos deve responder com o resultado da operação concluída anteriormente, seja um sucesso ou um erro.
UX de confirmação após o usuário ter saído da página
O problema difícil de UX é que a entrega diferida frequentemente ocorre após o usuário ter navegado para outra página, portanto você precisa de um mecanismo para reconciliar o estado otimista de “Enfileirado” assim que a requisição de fato for processada. Três abordagens cobrem esse cenário:
- Atualizar “Enfileirado” → “Enviado” em tempo real. Quando o service worker reenvia um item com sucesso, use
postMessagepara qualquer cliente aberto, permitindo que a interface seja atualizada no lugar. Escute comnavigator.serviceWorker.addEventListener('message', ...). - Reconciliar no próximo carregamento. Na inicialização, leia o estado drenado da fila: itens ainda presentes estão pendentes; a ausência deles significa que a entrega foi bem-sucedida. Renderize o status a partir do store, não de uma flag definida no momento do envio.
- Sinalizar falhas definitivas. Use
event.lastChanceno handlersyncpara disparar uma notificação (ou persistir um marcador de “falha” que a interface lê no próximo carregamento), para que uma submissão que esgotou suas tentativas não desapareça silenciosamente.
A UX de confirmação é o segundo lugar onde o session replay demonstra seu valor: o replay torna visível a linha do tempo do lado do usuário — a submissão enfileirada, a tela que ele viu, se a confirmação de “Enviado” chegou a ser renderizada — que é a única visão que revela a lacuna de confiança quando uma requisição nunca chegou ao servidor para ser registrada em log.
Workbox como atalho para produção
Se preferir não implementar a fila manualmente, o Workbox (atualmente na versão principal 7) fornece o BackgroundSyncPlugin, que enfileira requisições com falha no IndexedDB e as reenvia nos eventos sync. Importante: ele inclui seu próprio fallback — em navegadores que não suportam nativamente a API de BackgroundSync, o Workbox Background Sync tentará automaticamente um replay sempre que seu service worker for iniciado.
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSync = new BackgroundSyncPlugin('order-queue', {
maxRetentionTime: 24 * 60, // minutes; the documented example keeps items 24h
});
registerRoute(/\/api\/orders/, new NetworkOnly({ plugins: [bgSync] }), 'POST');
Dois comportamentos costumam surpreender as pessoas. Primeiro, o BackgroundSyncPlugin se conecta ao callback de plugin fetchDidFail, e fetchDidFail só é invocado quando uma exceção é lançada, mais provavelmente devido a uma falha de rede, o que significa que as requisições não serão reenviadas se uma resposta com status 4xx ou 5xx for recebida. Se você quiser que respostas 5xx sejam reenfileiradas, adicione um plugin fetchDidSucceed que lança uma exceção quando response.status >= 500. Segundo, ao testar, você pode verificar se as requisições foram enfileiradas acessando Chrome DevTools > Application > IndexedDB, e forçar um replay em DevTools > Application > Service Workers. Não valide o comportamento offline com a caixa de seleção “Offline” do DevTools — ela bloqueia requisições da página, mas permite que requisições do service worker passem, ocultando exatamente os bugs que você está testando. Desconecte a rede real em vez disso.
Seja implementando manualmente ou adotando o Workbox, a arquitetura é a mesma:
- capturar localmente antes da chamada de rede;
- preferir o Background Sync onde ele estiver disponível;
- recair sobre eventos
onlineem todos os outros ambientes; - deduplicar replays com uma idempotency key; e
- reconciliar a interface quando a entrega finalmente ocorrer.
Conecte essas cinco peças ao seu próprio formulário e, em seguida, verifique um replay real enfileirando offline, reconectando e confirmando que um único registro aparece no servidor.
Perguntas Frequentes
Qual é a diferença entre o Background Sync pontual e o Periodic Background Sync?
O Background Sync pontual usa a interface SyncManager para reenviar uma única tarefa diferida, como uma submissão de formulário enfileirada, assim que a conectividade é restaurada, e o navegador dispara um evento sync que pode chegar mesmo após o usuário navegar para outra página. O Periodic Background Sync usa uma interface separada, PeriodicSyncManager, para atualizações recorrentes em um cronograma gerenciado pelo navegador, como a atualização de conteúdo. São APIs distintas, e o Periodic Background Sync é experimental, portanto verifique sua tabela de compatibilidade antes de depender dele em produção.
Por que minha requisição enfileirada ainda falha ao tentar novamente quando o servidor retorna um erro 400 ou 500?
O Background Sync e o BackgroundSyncPlugin do Workbox tentam novamente apenas requisições que falham por um erro de rede genuíno, pois o plugin se conecta ao callback fetchDidFail, que só dispara quando uma exceção é lançada. Uma resposta 400 ou 500 é considerada uma resposta recebida, portanto é tratada como entregue e não será reenviada. Para reenfileirar respostas 5xx, adicione um plugin fetchDidSucceed que lança uma exceção quando response.status é 500 ou superior, ou lance manualmente em um handler sync implementado manualmente.
O Background Sync funciona dentro de um Android WebView que encapsula uma PWA?
Não. O Android WebView, o componente que aplicativos nativos incorporam para navegação in-app, não expõe o SyncManager, portanto uma PWA encapsulada em um shell WebView perde a fila nativa de Background Sync. A submissão recai sobre o caminho do evento online, que só dispara enquanto uma página da sua origem está aberta e não consegue ativar uma aba fechada. Por causa disso e da ausência de suporte no Firefox, Safari e iOS, uma fila no IndexedDB com um fallback de evento online deve permanecer como a base.
Como posso testar o comportamento do Background Sync offline de forma confiável?
Desconecte a rede real em vez de usar a caixa de seleção 'Offline' do DevTools. A caixa de seleção Offline bloqueia requisições da página, mas permite que requisições do service worker passem, ocultando exatamente os bugs que você está testando. Verifique os itens enfileirados no Chrome DevTools em Application e depois IndexedDB, e force um replay em Application e depois Service Workers. Para confirmar o fluxo completo, enfileire uma submissão offline, reconecte e verifique se um único registro aparece no servidor.