Cache reativo com Pinia + IndexedDB: estratégia offline-first no Vue 3
Por John Pires · 12 de março de 2026

Cenário prático do dia a dia
Times de produto que entregam aplicações PWA ou web apps progressivas enfrentam dois problemas recorrentes: inconsistência de dados quando a rede oscila e UX degradada por carregamentos desnecessários. Um padrão eficiente é manter um cache local reativo que sirva a UI imediatamente e sincronize em segundo plano com a API. Aqui vamos construir uma estratégia offline-first combinando Pinia para reatividade e IndexedDB para persistência durável.
O objetivo não é substituir um backend ou resolver sincronização complexa de conflitos, mas oferecer uma camada de cache simples, previsível e reativa que reduz latência na UI, permite uso offline e simplifica retrys automáticos. A abordagem que descrevo é aplicável a listas de recursos, detalhes de entidades e formulários simples.
Este cenário assume que você já usa Vue 3 com Composition API e Pinia. Vamos abordar um fluxo típico: carregar lista de itens, exibir cache imediatamente, atualizar em background, persistir mudanças locais e lidar com erros de IndexedDB ou limites de quota.
Antes de codificar, enumere requisitos técnicos práticos: 1) leitura instantânea da UI via cache reativo; 2) persistência entre sessões; 3) atualização em background sem bloquear render; 4) gerenciamento de versões do schema do cache; 5) tratamento de falhas de escrita/recuperação.
Por que Pinia + IndexedDB
Pinia fornece a reatividade que a UI consome diretamente. IndexedDB resolve persistência para grandes payloads e objetos complexos (diferente de localStorage que é síncrono e limitado). Integrá-los traz o melhor dos dois mundos: reatividade instantânea e durabilidade offline.
Com Pinia você mantém a lógica de leitura/gravação num único lugar: store. Observadores reativos podem escutar mudanças e acionar persistência. Separando o adaptador de IndexedDB como uma dependência injetável, você mantém testabilidade e modularidade.
Casos reais que vou cobrir
Exemplos práticos: lista de tarefas com cache, pré-preenchimento de formulário com dados offline, atualização de item em background, e rollback em caso de conflito ou falha de gravação. Também veremos migração de schema e limpeza de cache por versão.
Dica: pense o cache como uma cópia local que precisa de políticas claras de invalidação. Evite heurísticas implícitas; defina TTL, versão e gatilhos de sincronização.
Arquitetura da solução
A arquitetura proposta tem três camadas: API layer (fetch/axios), store reativo (Pinia) e persistence layer (IndexedDB). O fluxo básico: componente solicita dados ao store -> store retorna cache imediatamente -> store dispara fetch em background -> on success atualiza cache e IndexedDB -> on failure mantém cache e marca erro.
Key concepts:
- Cache as source of truth: UI consome sempre o estado do Pinia.
- Eventual consistency: network updates chegam e reconciliam estado local.
- Persistence adapter: encapsula todas as operações IndexedDB, incluindo versionamento e compactação.
Componentes da arquitetura
1) IndexedDB adapter - abstrai openDB, get, set, delete, clear e migracoes. 2) Pinia store - state reativo com actions que leem/persistem e expõem indicadores de loading/err. 3) Hooks de consumo - useCachedFetch que encapsula o fluxo de leitura do store e refresh em background.
Essa separação permite trocar IndexedDB por outro storage (por exemplo, um service worker cache) sem tocar componentes nem a maioria das ações da store.
Fluxos e políticas
Defina políticas básicas:
- Cache-first para leitura imediata.
- Background-refresh para garantir frescor eventual.
- TTL opcional por coleção, para invalidar after X minutos.
- Conflict handling: last-write-wins por padrão, com opção de merge em casos específicos.
Código completo com explicações
A seguir implemento três artefatos: adapter idb (utilizando idb), Pinia store com persistência reativa e um composable que componentes consomem. Cada bloco tem contexto e explicação do que acontece antes e depois do código.
IndexedDB adapter (idb)
O adapter encapsula openDB e operações básicas. Uso a biblioteca idb (https://github.com/jakearchibald/idb) para simplificar a API. O adapter tem versão e migração simples.
/* arquivo: src/lib/idb-adapter.ts
O que faz: cria/abre o DB, oferece get/set/remove/listAll e applyMigration.
Como usar: importar e chamar initDB(); depois usar get/set com a store. */
import { openDB } from 'idb'
const DB_NAME = 'app-cache-db'
const DB_VERSION = 1
const STORE_NAME = 'cache'
export async function initDB() {
const db = await openDB(DB_NAME, DB_VERSION, {
upgrade(upgradeDb, oldVersion, newVersion, transaction) {
if (!upgradeDb.objectStoreNames.contains(STORE_NAME)) {
upgradeDb.createObjectStore(STORE_NAME)
}
// aqui você pode aplicar migrações entre versões
}
})
return db
}
export async function idbGet(db, key) {
return await db.get(STORE_NAME, key)
}
export async function idbSet(db, key, value) {
return await db.put(STORE_NAME, value, key)
}
export async function idbDelete(db, key) {
return await db.delete(STORE_NAME, key)
}
export async function idbKeys(db) {
return await db.getAllKeys(STORE_NAME)
}
Depois da chamada initDB(), o adapter expõe promessas para operação. Em um ambiente de produção, trate quotas e erros (ver seção de erros abaixo).
Pinia store com persistência reativa
O store mantém o cache em memória e sincroniza com IndexedDB. Uso watch para persistir mudanças de forma debounced, evitando chamadas excessivas ao disco.
/* arquivo: src/stores/useCacheStore.ts
O que faz: define um store Pinia que carrega do IndexedDB no init
e persiste alterações. Também expõe fetchWithCache para carregar do backend. */
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { initDB, idbGet, idbSet } from '@/lib/idb-adapter'
export const useCacheStore = defineStore('cache', () => {
const dbPromise = initDB()
const cache = ref({})
const loading = ref(false)
const errors = ref({})
// carrega do IDB na inicialização
;(async () => {
try {
const db = await dbPromise
const stored = await idbGet(db, 'app_cache')
if (stored) cache.value = stored
} catch (e) {
console.error('idb load error', e)
}
})()
async function persist() {
try {
const db = await dbPromise
await idbSet(db, 'app_cache', cache.value)
} catch (e) {
console.error('idb save error', e)
throw e
}
}
// persiste mudanças com debounce simples
let persistTimeout
watch(cache, () => {
clearTimeout(persistTimeout)
persistTimeout = setTimeout(() => {
persist().catch(() => {
// já logado, opcional: marcar indicador de erro
})
}, 300)
}, { deep: true })
async function fetchWithCache(key, fetcher) {
loading.value = true
errors.value[key] = null
// serve cache imediato
const cached = cache.value[key]
// dispara fetch em background
try {
const fresh = await fetcher()
cache.value = { ...cache.value, [key]: fresh }
loading.value = false
return fresh
} catch (e) {
loading.value = false
errors.value[key] = e
// retorna cache mesmo que stale
if (cached !== undefined) return cached
throw e
}
}
return { cache, loading, errors, fetchWithCache }
})
Após a store carregar, componentes podem consumir cache.value e usar fetchWithCache para atualizar. O watch garante persistir no IDB quando a store muda.
Composable de consumo no componente
Este composable é usado em componentes Vue para encapsular o padrão: buscar recursos com cache e refrescar na montagem.
/* arquivo: src/composables/useCachedApi.ts
O que faz: wrapper para usar o cache store e fornecer estado para componentes.
Uso: const { data, loading, refresh } = useCachedApi('todos', () => fetchTodos()) */
import { useCacheStore } from '@/stores/useCacheStore'
import { computed, onMounted } from 'vue'
export function useCachedApi(key, fetcher) {
const store = useCacheStore()
const data = computed(() => store.cache[key])
const loading = computed(() => store.loading)
const error = computed(() => store.errors[key])
async function refresh() {
return store.fetchWithCache(key, fetcher)
}
onMounted(() => {
// se não houver cache, tenta buscar
if (!store.cache[key]) {
refresh().catch(() => {})
} else {
// sempre tenta atualizar em background
refresh().catch(() => {})
}
})
return { data, loading, error, refresh }
}
Com esse composable, um componente consome dados reativos, recebe atualizações automaticamente e não precisa conhecer IndexedDB nem regras de persistência.
Tratamento de erros e edge cases
IndexedDB e ambiente offline trazem várias armadilhas: falhas por quota, objetos não serializáveis, migração de schema e concorrência entre abas. Aqui estão estratégias práticas para cada caso.
Quota, writes e fallback
Browsers podem recusar writes quando o storage atinge quota. Estratégia: detectar erro na escrita e tentar fallback para localStorage apenas com metadados essenciais. Não armazene blobs grandes no fallback.
Exemplo de tratamento: ao falhar idbSet, remover entradas antigas menos usadas e tentar novamente. Use LRU simples com timestamps de lastAccess.
Migração de schema e versão
Inclua versionamento no DB e no objeto salvo. Ao detectar versão diferente, aplique migração controlada ou invalide o cache. Migrations devem ser idempotentes e registradas no adapter.
Se a migração falhar, registre erro e opte por limpar cache e alertar o usuário de forma não intrusiva.
Prática: sempre inclua um campo _meta: { version: X, ts: Date.now() } no objeto salvo para facilitar diagnóstico e migrações.
Performance e otimização
Persistência frequente pode degradar performance. Estratégias: debouncing de gravações, gravações incrementais por key em vez de gravar todo o objeto cache, compressão de payloads e limpeza periódica de dados antigos.
Gravação incremental
Em vez de idbSet(db, 'app_cache', cache) gravar o objeto inteiro, mantenha itens por key e salve somente a key que mudou. Isso reduz I/O e evita escritas concorrentes longas.
Refatore o adapter para usar object store keys individuais e no store chamar idbSet(db, key, value) para cada entry atualizada.
Debounce e batch
Use debounce e batch para agrupar mudanças frequentes (ex: digitação no UI). O exemplo anterior já aplica debounce de 300ms; ajuste conforme perfil do app. Para listas grandes, considere batch de 1s e persistência incremental.
Erros comuns, armadilhas e dicas práticas
Erros comuns
1) Serialização: funções e instâncias de classe não são serializáveis em IndexedDB. Salve objetos puros ou serialize manualmente. 2) Concurrency: várias abas escrevendo o mesmo key podem sobrescrever. Use last-write-wins ou canais de broadcast para sync entre abas.
3) Esquecer de lidar com promises: operações IDB são assíncronas; não trate writes como sincronos. Isso evita condições de corrida e perda de dados.
Dicas práticas
- Inclua um indicador de 'stale' por item baseado em TTL para dar contexto ao usuário.
- Evite gravar dados sensíveis sem criptografia.
- Teste limites: encha o storage em ambiente de teste para ver comportamento de fallback.
Conclusão prática
O padrão cache-first com Pinia + IndexedDB oferece UX responsiva e suporte offline sem grande complexidade. A arquitetura proposta mantém a UI consumindo um único estado reativo, enquanto um adapter de IndexedDB garante durabilidade e versionamento. Coube aqui padrões de persistência incremental, debouncing e tratamento de erros práticos como fallback para quota e migrações.
Implementação recomendada: começar simples (cache por key, persistência debounced), medir e otimizar gravações, e adicionar migrações e LRU conforme necessidade. Use o composable para isolar complexidade e manter componentes limpos.
Resumo prático: 1) mantenha cache no Pinia; 2) persista no IndexedDB via adapter; 3) sirva cache imediato e atualize em background; 4) trate erros de escrita e migração; 5) otimize gravações com debounce e persistência incremental.
Aplicando essas práticas, você terá uma base sólida para features offline-first no Vue 3 sem perder a previsibilidade e controle sobre dados persistidos.
Assuntos: Vue.js, pinia, indexeddb, offline-first, Performance
Leia também
Evitar re-renders no Vue 3: shallowRef, markRaw e storeToRefs (Pinia)
Guia prático para reduzir re-renders no Vue 3: quando usar shallowRef, markRaw e storeToRefs (Pinia). Padrões, exemplos funcionais e armadilhas para melhorar…
Hydration parcial no Vue 3: otimizar SSR com Pinia, Suspense e lazy components
Padrão prático de partial hydration em Vue 3: como transferir estado do Pinia, montar islands com Suspense e lazy components, e reduzir TTI sem hidratar a…
Implementando ISR em Nuxt 3 (Nitro): gerar, invalidar e cache por rota
Padrão prático de ISR em Nuxt 3 (Nitro): cache por rota de dados, stale-while-revalidate, revalidação em background e endpoint seguro de invalidação. Código…
Virtualização reativa em Vue 3: Composition API + Pinia para listas gigantes
Prático e direto: como substituir o anti-pattern de renderizar tudo por uma virtualização reativa em Vue 3 usando Composition API e Pinia, com padrões…
Scroll infinito reativo no Vue 3 com Composition API e Pinia
Aprenda a implementar scroll infinito reativo em Vue 3 usando Composition API e Pinia: do setup ao mini-projeto, com exemplos práticos, store…
Tabela paginada reativa em Vue 3: Composition API e Pinia passo a passo
Implementação prática de uma tabela paginada reativa em Vue 3 usando Composition API e Pinia. Guia passo a passo com store, componente, mock de API, erros…