IndexedDB com Claude Code: guia prático de dados locais
Implemente IndexedDB com Claude Code: schema, migrações, índices, transações, quota, sync offline e testes.
IndexedDB é o banco de dados do navegador para quando localStorage deixa de ser suficiente. Ele guarda objetos estruturados, usa índices, trabalha de forma assíncrona e agrupa operações em transações. Para rascunhos offline, cache pesquisável, fila de sincronização, metadados de mídia ou resultados grandes gerados por IA, IndexedDB costuma ser a escolha certa.
O problema não é fazer uma demo. O problema é desenhar o schema, migrar versões sem quebrar usuários antigos, manter transações curtas, tratar QuotaExceededError e decidir o que acontece quando uma edição offline entra em conflito com dados novos do servidor. Se você pedir apenas “adicione IndexedDB” ao Claude Code, o código pode funcionar no caminho feliz e falhar na primeira mudança de version ou na primeira reconexão.
Este guia usa TypeScript com o wrapper idb. As referências primárias são MDN Using IndexedDB, MDN storage quotas and eviction criteria, web.dev Storage for the web, o README do idb e a documentação do Dexie.js. Para prompts mais seguros com Claude Code, leia também dicas de produtividade e estratégias de teste.
Quando IndexedDB vence localStorage
localStorage é bom para strings pequenas: tema, idioma, último painel aberto, uma preferência curta. Ele é simples, mas síncrono, bloqueia a thread principal e não tem índices. Quando você começa a salvar uma lista grande como JSON e reescreve tudo a cada alteração, IndexedDB passa a ser mais adequado.
IndexedDB funciona como um pequeno banco local. Object store guarda registros. Key identifica cada registro. Index permite buscar por outro campo. Transaction agrupa leituras e escritas para evitar estado pela metade. Ele não substitui o banco do servidor, mas melhora a experiência em rede instável e reduz requisições repetidas.
| Fluxo | localStorage | IndexedDB |
|---|---|---|
| Tema, modo de UI, preferência curta | Bom | Geralmente exagero |
| Rascunho longo ou recuperação de formulário | Frágil e bloqueante | Bom |
| Cache pesquisável de artigos ou produtos | Vira JSON gigante | Use índices |
| Blob, anexos, estado de mídia | Ruim | Prático |
| Fila de ações offline | Ordem e retry difíceis | Bom com transações |
Use cases reais aparecem em várias apps. Um CMS salva rascunhos com updatedAt e syncStatus. Um dashboard SaaS guarda produtos, planos ou artigos de ajuda e consulta por categoria ou atualização. Uma PWA salva comentários, formulários de contato ou mudanças administrativas em uma fila offline. Uma ferramenta com Claude Code pode guardar resultados grandes de análise com expiração para não repetir chamadas caras.
Planeje schema, version e índices antes
A primeira pergunta não é “o que salvar?”, mas “como vou ler depois?”. Buscar só por id é simples. Apps reais precisam de “rascunhos dirty”, “cache mais antigo”, “jobs por createdAt” ou “tarefas com poucas tentativas”. Isso exige índices. Adicionar um índice depois exige subir a version e escrever uma migração.
Um erro comum é criar apenas o store notes e depois filtrar tudo em JavaScript porque falta o índice syncStatus. Com poucos registros parece aceitável; com milhares, o carregamento fica pesado. Outro erro é usar version decimal como 1.1. Trate versões como inteiros: 1, 2, 3.
npm i idb
O módulo abaixo pode ficar em src/lib/local-db.ts em uma app cliente. idb mantém o modelo do IndexedDB visível. Se você precisa de helpers de query mais ricos, live queries ou uma camada de dados mais alta, Dexie também é uma opção; aqui usamos idb para manter o começo simples.
import {
openDB,
type DBSchema,
type IDBPDatabase,
} from "idb";
type SyncStatus = "clean" | "dirty" | "syncing" | "failed";
export interface DraftNote {
id: string;
title: string;
body: string;
updatedAt: number;
baseVersion: number;
syncStatus: SyncStatus;
}
export interface SyncJob {
id: string;
noteId: string;
operation: "upsert-note" | "delete-note";
payload: unknown;
createdAt: number;
attempts: number;
lastError?: string;
}
interface AppDB extends DBSchema {
notes: {
key: string;
value: DraftNote;
indexes: {
"by-updated-at": number;
"by-sync-status": SyncStatus;
};
};
syncQueue: {
key: string;
value: SyncJob;
indexes: {
"by-created-at": number;
"by-attempts": number;
};
};
}
const DB_NAME = "claude-code-indexeddb-demo";
const DB_VERSION = 2;
let dbPromise: Promise<IDBPDatabase<AppDB>> | undefined;
export function getDb() {
dbPromise ??= openDB<AppDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, _newVersion, tx) {
if (oldVersion < 1) {
const notes = db.createObjectStore("notes", {
keyPath: "id",
});
notes.createIndex("by-updated-at", "updatedAt");
const queue = db.createObjectStore("syncQueue", {
keyPath: "id",
});
queue.createIndex("by-created-at", "createdAt");
queue.createIndex("by-attempts", "attempts");
}
if (oldVersion < 2) {
const notes = tx.objectStore("notes");
if (!notes.indexNames.contains("by-sync-status")) {
notes.createIndex("by-sync-status", "syncStatus");
}
}
},
blocked() {
console.warn("Close other tabs to finish the DB upgrade.");
},
blocking() {
dbPromise?.then((db) => db.close()).catch(() => {});
},
});
return dbPromise;
}
Mudanças estruturais ficam em upgrade. Não crie stores ou índices dentro de funções normais de leitura. Se outra aba estiver com a versão antiga aberta, a nova versão pode ficar bloqueada. blocked deve orientar o usuário, e blocking fecha a conexão atual para não atrapalhar futuras migrações.
Salve rascunho e fila na mesma transaction
Salvar o rascunho e colocar um job na fila é uma única ação de negócio. Se a nota for salva e a fila falhar, a UI mostra “salvo” enquanto o servidor nunca recebe a mudança. Use uma transaction readwrite nos dois stores.
const createId = () =>
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2);
export async function saveDraft(input: {
id?: string;
title: string;
body: string;
baseVersion?: number;
}) {
const db = await getDb();
const now = Date.now();
const noteId = input.id ?? createId();
const note: DraftNote = {
id: noteId,
title: input.title,
body: input.body,
updatedAt: now,
baseVersion: input.baseVersion ?? 0,
syncStatus: "dirty",
};
const job: SyncJob = {
id: createId(),
noteId,
operation: "upsert-note",
payload: note,
createdAt: now,
attempts: 0,
};
const tx = db.transaction(["notes", "syncQueue"], "readwrite");
await tx.objectStore("notes").put(note);
await tx.objectStore("syncQueue").put(job);
await tx.done;
return note;
}
export async function getDirtyNotes() {
const db = await getDb();
return db.getAllFromIndex("notes", "by-sync-status", "dirty");
}
Não faça await fetch() dentro de uma transaction ativa. Quando o navegador não vê mais operações IndexedDB pendentes, ele pode fechar a transaction. Faça o trabalho de rede fora dela e abra outra transaction para gravar o resultado.
Trate quota, eviction e erro visível
IndexedDB não é infinito. O navegador pode recusar writes se a origin passar da quota, e dados best effort podem ser removidos sob pressão de armazenamento. Modo privado também pode mudar o comportamento. Planeje tratamento de erro, limpeza de cache antigo e sync para servidor.
Dois erros são frequentes. Primeiro, QuotaExceededError aparece apenas no console e a tela continua dizendo “salvo”. Segundo, dados críticos de pagamento, contrato, estoque ou suporte ficam só no IndexedDB. Para esses casos, IndexedDB é área offline ou fila, não fonte definitiva.
export async function estimateStorage() {
if (!navigator.storage?.estimate) {
return { usage: undefined, quota: undefined };
}
const { usage, quota } = await navigator.storage.estimate();
return { usage, quota };
}
export async function requestPersistentStorage() {
if (!navigator.storage?.persist) {
return false;
}
return navigator.storage.persist();
}
export function isQuotaError(error: unknown) {
return (
error instanceof DOMException &&
error.name === "QuotaExceededError"
);
}
export async function saveDraftWithErrorMessage(
input: Parameters<typeof saveDraft>[0],
) {
try {
return await saveDraft(input);
} catch (error) {
if (isQuotaError(error)) {
throw new Error(
"O armazenamento do navegador está cheio. Apague rascunhos antigos.",
);
}
throw error;
}
}
persist() é uma solicitação, não uma garantia. Peça ao Claude Code para incluir mensagem de UI, limpeza de cache e testes, não só um try/catch.
Fila offline e conflitos de sincronização
Uma fila offline precisa de ordem, retry, deduplicação e regra de conflito. Se uma nota é editada offline em um dispositivo e online em outro, reenviar a operação antiga pode sobrescrever dados mais novos.
type SendJob = (job: SyncJob) => Promise<void>;
export async function flushSyncQueue(sendJob: SendJob) {
if (!navigator.onLine) return;
const db = await getDb();
const jobs = await db.getAllFromIndex(
"syncQueue",
"by-created-at",
);
for (const job of jobs) {
try {
await sendJob(job);
await db.delete("syncQueue", job.id);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown sync error";
await db.put("syncQueue", {
...job,
attempts: job.attempts + 1,
lastError: message,
});
}
}
}
window.addEventListener("online", () => {
void flushSyncQueue(async (job) => {
const response = await fetch("/api/sync", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(job),
});
if (!response.ok) {
throw new Error(`Sync failed: ${response.status}`);
}
});
});
Para uma nota pessoal, mostrar versão local e remota pode bastar. Para estoque, reservas, invoices ou eventos de pagamento, o servidor deve decidir com version, ETag ou idempotency key.
export function detectConflict(input: {
local: DraftNote;
remoteVersion: number;
}) {
const { local, remoteVersion } = input;
return (
local.syncStatus === "dirty" &&
remoteVersion > local.baseVersion
);
}
Testes e limites do mock
fake-indexeddb permite rodar testes com Vitest em Node usando uma API parecida com IndexedDB. Use para salvar rascunho, consultar index, inserir fila e testar migrações. Não use como prova de quota, eviction, modo privado, bloqueio por abas ou pressão de armazenamento mobile. Isso exige navegador real.
npm i -D vitest fake-indexeddb
import "fake-indexeddb/auto";
import { beforeEach, expect, test } from "vitest";
import { deleteDB } from "idb";
import {
getDirtyNotes,
saveDraft,
} from "./local-db";
beforeEach(async () => {
await deleteDB("claude-code-indexeddb-demo");
});
test("saves a draft and finds it by sync status", async () => {
await saveDraft({
title: "IndexedDB note",
body: "Saved while offline",
});
const dirtyNotes = await getDirtyNotes();
expect(dirtyNotes).toHaveLength(1);
expect(dirtyNotes[0]?.syncStatus).toBe("dirty");
});
Antes de publicar, confirme: primeira carga cria o DB, version 2 adiciona syncStatus, outra aba mostra aviso de upgrade, rascunho offline sobrevive ao reload, fila envia ao voltar online e erro de armazenamento é claro. Em páginas monetizadas, CTA, links de produto, consulta e analytics precisam continuar intactos.
Prompt seguro para Claude Code
O prompt precisa definir contrato de dados local. “Deixe offline” é vago demais para IndexedDB.
claude <<'PROMPT'
Scope:
- Edit only src/lib/local-db.ts and src/lib/local-db.test.ts.
- Do not change API routes, pricing copy, analytics, or CTA links.
Build:
- Use IndexedDB through the idb package.
- Database name: claude-code-indexeddb-demo.
- Version 2 must create notes and syncQueue stores.
- Add indexes for updatedAt, syncStatus, createdAt, and attempts.
Reliability:
- Use readwrite transactions for note + queue writes.
- Do not await fetch inside an active IndexedDB transaction.
- Handle QuotaExceededError with a user-facing message.
- Explain blocked upgrades from another open tab.
Testing:
- Use vitest and fake-indexeddb.
- Test draft save, dirty-note lookup, and queue insertion.
- Return findings, patch summary, commands, and residual risk.
PROMPT
Com esse nível de detalhe, Claude Code cria uma camada local revisável. Em site monetizado, drafts offline, cache e fila não podem quebrar CTAs, links de compra, formulários de consulta ou analytics. Times podem transformar isso em checklist com treinamento e consultoria Claude Code. Quem trabalha sozinho pode começar pelo cheatsheet gratuito e pelos templates Claude Code.
Nota de verificação prática
Masa testou este padrão em um pequeno editor de notas. O maior ganho veio de decidir syncStatus e updatedAt antes da primeira implementação. A versão inicial lia tudo e filtrava em JavaScript; com mais dados de teste, o carregamento ficou pesado. Com idb e uma transaction para nota + fila, os estados de falha ficaram mais fáceis de revisar. fake-indexeddb validou salvar e buscar, mas não quota nem bloqueio por abas. A checagem final usou Chrome DevTools para limpar storage, colocar rede Offline, salvar, recarregar, voltar Online e confirmar o envio da fila.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.