Como usar Progressive Web App (PWA) para validação de CPF offline-first

Aprenda a criar uma PWA para validação de CPF com estratégia offline-first, usando Service Workers e sincronização em background.

Redação CPFHub.io
Redação CPFHub.io
··10 min de leitura
Como usar Progressive Web App (PWA) para validação de CPF offline-first

Progressive Web Apps com estratégia offline-first permitem validar CPFs mesmo sem conexão de internet, usando Service Workers para interceptar requisições e IndexedDB para armazenar resultados em cache. A validação local dos dígitos verificadores funciona 100% offline, enquanto a consulta completa na API CPFHub.io é sincronizada automaticamente quando a conexão é restabelecida.

Introdução

Em um país com dimensões continentais como o Brasil, nem sempre é possível contar com uma conexão de internet estável. Vendedores em campo, agentes de saúde em áreas rurais e profissionais em eventos com Wi-Fi congestionado precisam validar CPFs mesmo quando estão offline ou com conexão intermitente.

PWAs com estratégia offline-first resolvem esse problema. A aplicação funciona normalmente sem internet — fazendo validação local de dígitos verificadores — e sincroniza as consultas com a API da CPFHub.io quando a conexão volta. A especificação de Service Workers do W3C define o padrão técnico que viabiliza esse comportamento.

Arquitetura offline-first para validação de CPF

A estratégia offline-first inverte a lógica tradicional: em vez de tentar conectar à API e tratar a falha como exceção, o sistema assume que pode estar offline e trata a conexão como um bônus.

Camadas de validação

  1. Camada local (sempre disponível) — validação de formato e dígitos verificadores via algoritmo mod-11.
  2. Cache local (disponível após primeira consulta) — CPFs previamente consultados ficam armazenados em IndexedDB.
  3. API remota (disponível com internet) — consulta completa com nome, data de nascimento e gênero.

Configurando o Service Worker

O Service Worker é o coração da PWA. Ele intercepta requisições de rede e decide se deve usar o cache ou a rede.

// sw.js
const CACHE_NAME = 'cpf-validator-v1';
const STATIC_ASSETS = [
    '/',
    '/index.html',
    '/styles.css',
    '/app.js',
    '/manifest.json'
];

// Instalar: cachear assets estaticos
self.addEventListener('install', (event) => {
    event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
    );
    self.skipWaiting();
});

// Ativar: limpar caches antigos
self.addEventListener('activate', (event) => {
    event.waitUntil(
    caches.keys().then((names) =>
    Promise.all(
    names
    .filter((name) => name !== CACHE_NAME)
    .map((name) => caches.delete(name))
    )
    )
    );
    self.clients.claim();
});

// Fetch: estrategia network-first para API, cache-first para assets
self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    if (url.hostname === 'api.cpfhub.io') {
    // API: network-first com fallback para cache
    event.respondWith(networkFirstAPI(event.request));
    } else {
    // Assets: cache-first
    event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request))
    );
    }
});

async function networkFirstAPI(request) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 10000);

    try {
    const response = await fetch(request, { signal: controller.signal });
    clearTimeout(timeoutId);

    // Cachear resposta bem-sucedida
    if (response.ok) {
    const cache = await caches.open('cpf-api-cache');
    cache.put(request, response.clone());
    }
    return response;
    } catch (err) {
    clearTimeout(timeoutId);
    // Fallback: tentar cache
    const cached = await caches.match(request);
    if (cached) return cached;

    // Sem cache: retornar resposta offline
    return new Response(
    JSON.stringify({
    success: false,
    offline: true,
    message: 'Consulta offline. Validacao local realizada.'
    }),
    { headers: { 'Content-Type': 'application/json' } }
    );
    }
}

Registrando o Service Worker

// app.js
if ('serviceWorker' in navigator) {
    window.addEventListener('load', async () => {
    try {
    const registration = await navigator.serviceWorker.register('/sw.js');
    console.log('SW registrado:', registration.scope);
    } catch (err) {
    console.error('Erro ao registrar SW:', err);
    }
    });
}

Cache de consultas com IndexedDB

Para armazenar resultados de consultas de CPF de forma persistente, usamos IndexedDB — que suporta dados estruturados e não tem os limites de tamanho do localStorage.

// db.js
class CPFDatabase {
    constructor() {
    this.dbName = 'cpf-validator';
    this.storeName = 'consultas';
    this.db = null;
    }

    async open() {
    return new Promise((resolve, reject) => {
    const request = indexedDB.open(this.dbName, 1);

    request.onupgradeneeded = (event) => {
    const db = event.target.result;
    if (!db.objectStoreNames.contains(this.storeName)) {
    const store = db.createObjectStore(this.storeName, { keyPath: 'cpf' });
    store.createIndex('timestamp', 'timestamp');
    }
    };

    request.onsuccess = (event) => {
    this.db = event.target.result;
    resolve(this.db);
    };

    request.onerror = () => reject(request.error);
    });
    }

    async salvar(cpf, dados) {
    const tx = this.db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    store.put({
    cpf,
    dados,
    timestamp: Date.now(),
    sincronizado: true
    });
    return new Promise((resolve) => { tx.oncomplete = resolve; });
    }

    async buscar(cpf) {
    const tx = this.db.transaction(this.storeName, 'readonly');
    const store = tx.objectStore(this.storeName);
    const request = store.get(cpf);
    return new Promise((resolve) => {
    request.onsuccess = () => {
    const result = request.result;
    if (!result) { resolve(null); return; }

    // Cache valido por 24 horas
    const umDia = 24 * 60 * 60 * 1000;
    if (Date.now() - result.timestamp > umDia) {
    resolve(null);
    return;
    }
    resolve(result.dados);
    };
    request.onerror = () => resolve(null);
    });
    }

    async salvarPendente(cpf) {
    const tx = this.db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    store.put({
    cpf,
    dados: null,
    timestamp: Date.now(),
    sincronizado: false
    });
    return new Promise((resolve) => { tx.oncomplete = resolve; });
    }

    async getPendentes() {
    const tx = this.db.transaction(this.storeName, 'readonly');
    const store = tx.objectStore(this.storeName);
    const request = store.getAll();
    return new Promise((resolve) => {
    request.onsuccess = () => {
    resolve(request.result.filter((item) => !item.sincronizado));
    };
    });
    }
}

const cpfDB = new CPFDatabase();

Fluxo de consulta offline-first

A função principal de consulta implementa a lógica de três camadas.

async function consultarCPFOfflineFirst(digits) {
    await cpfDB.open();

    // Camada 1: Validacao local
    if (!validarDigitosCPF(digits)) {
    return { success: false, error: 'CPF invalido (validacao local).' };
    }

    // Camada 2: Cache local
    const cached = await cpfDB.buscar(digits);
    if (cached) {
    return { success: true, data: cached, source: 'cache' };
    }

    // Camada 3: API remota
    if (navigator.onLine) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 10000);

    try {
    const res = await fetch(`https://api.cpfhub.io/cpf/${digits}`, {
    headers: {
    'x-api-key': 'SUA_CHAVE_DE_API',
    'Accept': 'application/json'
    },
    signal: controller.signal
    });
    clearTimeout(timeoutId);
    const json = await res.json();

    if (json.success) {
    await cpfDB.salvar(digits, json.data);
    return { success: true, data: json.data, source: 'api' };
    }
    return { success: false, error: 'CPF nao encontrado.' };
    } catch (err) {
    clearTimeout(timeoutId);
    // Falha na rede -- salvar como pendente
    await cpfDB.salvarPendente(digits);
    return {
    success: true,
    data: null,
    source: 'offline',
    message: 'CPF valido localmente. Consulta completa sera feita quando houver conexao.'
    };
    }
    }

    // Sem internet -- salvar como pendente
    await cpfDB.salvarPendente(digits);
    return {
    success: true,
    data: null,
    source: 'offline',
    message: 'Voce esta offline. O CPF e valido localmente e sera consultado quando a conexao voltar.'
    };
}

function validarDigitosCPF(cpf) {
    if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false;
    let soma = 0;
    for (let i = 0; i < 9; i++) soma += parseInt(cpf[i]) * (10 - i);
    let resto = (soma * 10) % 11;
    if (resto === 10) resto = 0;
    if (resto !== parseInt(cpf[9])) return false;
    soma = 0;
    for (let i = 0; i < 10; i++) soma += parseInt(cpf[i]) * (11 - i);
    resto = (soma * 10) % 11;
    if (resto === 10) resto = 0;
    return resto === parseInt(cpf[10]);
}

Sincronização em background

Quando a conexão é restabelecida, a PWA precisa sincronizar as consultas pendentes. A Background Sync API é ideal para isso.

// No app.js: registrar sync quando voltar online
window.addEventListener('online', async () => {
    if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-cpf-pendentes');
    } else {
    // Fallback: sincronizar diretamente
    sincronizarPendentes();
    }
});

async function sincronizarPendentes() {
    await cpfDB.open();
    const pendentes = await cpfDB.getPendentes();

    for (const item of pendentes) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 10000);

    try {
    const res = await fetch(`https://api.cpfhub.io/cpf/${item.cpf}`, {
    headers: {
    'x-api-key': 'SUA_CHAVE_DE_API',
    'Accept': 'application/json'
    },
    signal: controller.signal
    });
    clearTimeout(timeoutId);
    const json = await res.json();

    if (json.success) {
    await cpfDB.salvar(item.cpf, json.data);
    notificarUsuario(item.cpf, json.data);
    }
    } catch (err) {
    clearTimeout(timeoutId);
    // Tentar novamente na proxima sincronizacao
    }
    }
}

function notificarUsuario(cpf, dados) {
    if ('Notification' in window && Notification.permission === 'granted') {
    new Notification('CPF sincronizado', {
    body: `O CPF ${cpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4')} foi verificado: ${dados.name}`,
    icon: '/icons/icon-192.png'
    });
    }
}

Indicador de status de conexão

A interface deve comunicar claramente se o usuário está online ou offline.

<div class="connection-status" id="connectionStatus">
    <span class="status-dot"></span>
    <span class="status-text"></span>
</div>

<style>
    .connection-status {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    padding: 6px 12px;
    font-size: 0.8rem;
    text-align: center;
    transition: transform 0.3s ease, background 0.3s ease;
    z-index: 1000;
    }
    .connection-status.online {
    background: #d4edda;
    color: #155724;
    transform: translateY(-100%);
    }
    .connection-status.offline {
    background: #fff3cd;
    color: #856404;
    transform: translateY(0);
    }
    .connection-status.syncing {
    background: #cce5ff;
    color: #004085;
    transform: translateY(0);
    }
    .status-dot {
    display: inline-block;
    width: 8px; height: 8px;
    border-radius: 50%;
    margin-right: 6px;
    vertical-align: middle;
    }
    .online .status-dot { background: #28a745; }
    .offline .status-dot { background: #ffc107; }
    .syncing .status-dot { background: #007bff; animation: pulse 1s infinite; }
    @keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.4; }
    }
</style>

<script>
    function updateConnectionStatus() {
    const el = document.getElementById('connectionStatus');
    const dot = el.querySelector('.status-dot');
    const text = el.querySelector('.status-text');

    if (navigator.onLine) {
    el.className = 'connection-status online';
    text.textContent = 'Online';
    // Esconder apos 2 segundos
    setTimeout(() => { el.style.transform = 'translateY(-100%)'; }, 2000);
    } else {
    el.className = 'connection-status offline';
    text.textContent = 'Offline -- validacao local ativa';
    }
    }

    window.addEventListener('online', updateConnectionStatus);
    window.addEventListener('offline', updateConnectionStatus);
    updateConnectionStatus();
</script>

Web App Manifest

Para que a PWA seja instalável, configure o manifest.

{
    "name": "Validador de CPF",
    "short_name": "CPF Validator",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#ffffff",
    "theme_color": "#3498db",
    "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
    ]
}

Perguntas frequentes

Como o Service Worker decide quando usar cache ou rede para consultas de CPF?

A estratégia network-first tenta a rede primeiro com timeout de 10 segundos. Se a requisição falha ou o dispositivo está offline, o Service Worker busca a resposta no cache do CPF consultado anteriormente. Apenas quando nem rede nem cache estão disponíveis, o sistema retorna uma resposta offline indicando que a validação local foi realizada.

É possível usar IndexedDB offline sem limites de armazenamento?

O IndexedDB não tem limite fixo no padrão — o navegador pode alocar até uma fração do armazenamento disponível no dispositivo. Na prática, para uma PWA de validação de CPF, cada registro ocupa menos de 1 KB, então milhares de consultas cabem com folga. Mantenha limpeza automática de registros com mais de 24 horas para evitar acúmulo desnecessário.

A Background Sync API funciona em todos os navegadores?

A Background Sync API tem suporte nativo no Chrome e Edge. No Firefox e Safari, o código usa o fallback direto — o evento online dispara sincronizarPendentes() assim que a conexão volta. A implementação no artigo já contempla essa compatibilidade com o bloco else do SyncManager.

O que acontece se o usuário consultar o mesmo CPF offline várias vezes?

A função consultarCPFOfflineFirst verifica primeiro o cache local no IndexedDB. Se o CPF já foi consultado nas últimas 24 horas, retorna o resultado em cache sem criar novas entradas pendentes. Apenas CPFs sem registro no cache são marcados como pendentes para sincronização posterior.


Conclusão

A combinação de PWA com estratégia offline-first resolve um problema real para profissionais que precisam validar CPFs em campo, sem garantia de conexão estável. A validação local por dígitos verificadores funciona 100% offline, o IndexedDB armazena consultas anteriores e a Background Sync garante que tudo seja reconciliado quando a internet voltar.

A API da CPFHub.io responde em ~900ms e se encaixa perfeitamente nessa arquitetura: quando há conexão, a consulta completa retorna nome, data de nascimento e gênero do titular. Quando não há, o fluxo offline-first assume o controle sem interromper a experiência do usuário.

Teste gratuitamente em cpfhub.io — 50 consultas por mês sem cartão de crédito, sem compromisso.

CPFHub.io

Pronto para integrar a API?

50 consultas gratuitas para testar agora. Sem cartão de crédito. Acesso imediato à documentação.

Redação CPFHub.io

Sobre a redação

Redação CPFHub.io

Time editorial especializado em APIs de CPF, identidade digital e compliance no mercado brasileiro. Produzimos guias técnicos, análises regulatórias e tutoriais sobre LGPD e KYC para desenvolvedores e líderes de produto.

WhatsAppFale conosco via WhatsApp