IndexedDB: API nativa, Dexie, queries, indices, migrations
5 min de leitura
Ate agora vimos Service Worker + Cache API pra resources HTTP (JS, CSS, imagens). Mas o usuario tambem gera dados (post, comentario, foto, draft) que precisam persistir offline. E' ai que entra o IndexedDB - o "banco de dados" do browser.
IndexedDB e' o storage certo pra
datasets grandes (>5MB, ate GBs),
queries indexadas, e transacoes
ACID. localStorage (5MB sync) nao
aguenta. IndexedDB aguenta - mas a API
nativa e' notoriamente verbose e
callback-based. Em 2026, voce usa
Dexie.js (wrapper moderno) que torna
IndexedDB usavel.
Se voce entende a API nativa do IndexedDB (quando usar, como funciona), o Dexie que simplifica 90%, indices pra queries rapidas, e migrations pra evoluir schema, voce sai de "save em localStorage" pra "app offline-first com banco local".
O essencial 🟢
IndexedDB em uma frase: banco NoSQL orientado a objeto no browser, com transacoes ACID e queries indexadas. E' o storage certo pra dados estruturados que persistem entre sessoes e excedem 5MB.
| Storage | Tamanho | Sync | Queries | Caso de uso |
|---|---|---|---|---|
localStorage | ~5MB | sim | nao | UI prefs |
sessionStorage | ~5MB | sim | nao | Form drafts |
IndexedDB | ~50% disco | nao | sim (indices) | Cache de dados, offline-first |
Cache API | depende do browser | nao | nao | Service Worker resources |
IndexedDB persiste entre sessoes (ate o usuario limpar dados do site), e suporta transactions (ACID) e indices (queries por campo, O(log n)).
API nativa do IndexedDB - verbosa mas poderosa. A API nativa usa request objects e eventos (estilo XHR):
// API nativa - CRUD basico
const request = indexedDB.open("my-db", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains("users")) {
const store = db.createObjectStore("users", { keyPath: "id" });
store.createIndex("email", "email", { unique: true });
}
};
request.onsuccess = (event) => {
const db = event.target.result;
// Adicionar
const tx = db.transaction("users", "readwrite");
const store = tx.objectStore("users");
store.add({ id: 1, name: "Ana", email: "ana@example.com" });
// Buscar
const getReq = store.get(1);
getReq.onsuccess = () => console.log(getReq.result);
};
Voce abre o DB, escuta eventos
(onupgradeneeded, onsuccess,
onerror), cria stores (tabelas),
abre transacoes (readwrite/readonly),
adiciona/busca objetos. Verbosissimo -
50 linhas pra CRUD basico. Por isso, use
Dexie em prod.
Dexie.js - o wrapper idiomatico. Dexie reduz o mesmo CRUD a 3 linhas:
import Dexie from "dexie";
const db = new Dexie("my-db");
db.version(1).stores({
users: "++id, email", // ++id = auto-increment, email = index
});
// Adicionar
await db.users.add({ name: "Ana", email: "ana@example.com" });
// Buscar
const user = await db.users.get(1);
// Buscar por index
const byEmail = await db.users.where("email").equals("ana@example.com").first();
// Listar todos
const all = await db.users.toArray();
// Atualizar
await db.users.update(1, { name: "Ana Silva" });
// Deletar
await db.users.delete(1);
Dexie:
- Schema declarativo -
++id, email= auto-increment primary key + index ememail. - Promises (nao callbacks) - async/await funciona.
- Queries fluentes -
where().equals().first(). - Transactions implicitas - Dexie cria transacao automaticamente.
schema no Dexie - o equivalente ao
CREATE TABLE. No version().stores(),
voce declara o schema:
db.version(1).stores({
users: "++id, email, [userId+postId]", // composite index
posts: "++id, userId, createdAt",
});
++id: primary key auto-increment (integer).id: primary key (deve ser unique no objeto).name: index simples.[userId+postId]: composite index (queries por ambos).
Indices e queries - O(log n). Sem indice, query por campo e' O(n) (scan inteiro). Com indice, O(log n) (B-tree).
// SEM indice: scan inteiro
const user = await db.users.filter((u) => u.email === "ana@x.com").first();
// O(n)
// COM indice (declarado no schema):
const user = await db.users.where("email").equals("ana@x.com").first();
// O(log n)
Em datasets grandes (>1000 items), indice faz diferenca brutal. Sempre declare indices pros campos que voce vai buscar.
Compound queries (and / or). Para
queries multi-campo:
// AND (compound index)
const posts = await db.posts
.where("[userId+status]").equals([1, "published"])
.toArray();
// OR (usar .filter apos .where)
const posts = await db.posts
.where("userId").equals(1)
.or("status").equals("draft")
.toArray();
Ou com filter (post-fetch, mais lento):
const posts = await db.posts
.where("userId").equals(1)
.filter((p) => p.status === "published" || p.title.includes("urgent"))
.toArray();
Migrations - evoluir schema sem perder dados. IndexedDB nao aceita alterar schema inplace. Voce precisa de uma nova versao:
// v1: schema inicial
db.version(1).stores({
users: "++id, email",
});
// v2: adicionar campo `avatar`
db.version(2).stores({
users: "++id, email, avatar", // novo campo no schema
}).upgrade(async (tx) => {
// Migra dados: adiciona avatar default em users existentes
await tx.table("users").toCollection().modify((user) => {
user.avatar = "/default-avatar.png";
});
});
// v3: nova tabela `posts`
db.version(3).stores({
users: "++id, email, avatar",
posts: "++id, userId, createdAt", // nova tabela
});
NUNCA edite o schema de uma versao existente. Sempre crie nova versao. Senao, usuarios existentes quebram a app ("version error" no DevTools).
bulkPut, bulkAdd, bulkDelete - pra
muitos records. Inserir 1 por 1 e' lento
(devtools/UI travam). Use bulk:
// Inserir 1000 users de uma vez
await db.users.bulkAdd([
{ name: "Ana", email: "ana@x.com" },
{ name: "Bob", email: "bob@x.com" },
// ...
]);
Bulk operations sao 10-100x mais rapidas que loops. Use em importacao de dados, sync inicial, etc.
transaction explicita pra operacoes
multi-store. Quando voce precisa
atomicidade entre multiplas tabelas
(rollback se uma falhar):
await db.transaction("rw", db.users, db.posts, async () => {
const userId = await db.users.add({ name: "Ana" });
await db.posts.add({ userId, title: "Post da Ana" });
// Se posts.add falhar, users.add tb da rollback
});
Transactions em Dexie sao zone-based
- tudo dentro de
db.transaction()roda atomicamente. Util pra "criar user + perfil inicial" (2 inserts relacionados).
Aprofundamento 🟡
IndexedDB quotas e limites. O
IndexedDB usa ate 50% do disco disponivel
(Chrome, Firefox, Safari). Mas cada origin
tem quota (varia por browser, ~10% do
disco no Chrome). Se exceder, QuotaExceededError.
Como descobrir a quota:
const estimate = await navigator.storage.estimate();
console.log(`Usado: ${estimate.usage / 1e6} MB`);
console.log(`Quota: ${estimate.quota / 1e6} MB`);
if (estimate.usage / estimate.quota > 0.8) {
// 80% cheio - limpar caches antigos
console.warn("Storage quase cheio");
}
IndexedDB em modo privado Safari. Em Safari modo privado (janela anonima), IndexedDB e' tratado como in-memory - nao persiste entre sessoes. Mesmo codigo, comportamento diferente. Teste em Safari privado se o user usa esse modo.
live queries no Dexie - reagir a
mudancas. Dexie oferece observables
que re-emitem quando o DB muda:
import { useLiveQuery } from "dexie-react-hooks";
function UserList() {
const users = useLiveQuery(() => db.users.toArray());
// users atualiza automaticamente quando db.users.add() e' chamado
return <ul>{users?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
Excelente pra UI que reflete o DB em
tempo real. Sem polling, sem
WebSocket proprio. useLiveQuery (em
dexie-react-hooks) e' o pattern moderno
pra apps com dados locais.
Criptografia em IndexedDB. IndexedDB e' plaintext no disco. Se o usuario tem dados sensiveis (notas, passwords, chaves), criptografe antes de salvar:
import { encrypt, decrypt } from "./crypto";
// Save
const encrypted = await encrypt(JSON.stringify(secret), key);
await db.secrets.add({ id: 1, data: encrypted });
// Load
const row = await db.secrets.get(1);
const decrypted = JSON.parse(await decrypt(row.data, key));
Web Crypto API + chave derivada de
password do usuario. Crypto em si
cobre em seguranca-frontend. Aqui, saiba
que IndexedDB nao protege dados
sensiveis por default.
Sync entre abas via BroadcastChannel. A mesma DB em multiplas abas tem duplicacao de dados. Pattern de sync entre abas:
// Tab A
await db.users.add({ name: "Ana" });
const channel = new BroadcastChannel("db-changes");
channel.postMessage({ type: "add", table: "users", id: 1 });
// Tab B
const channel = new BroadcastChannel("db-changes");
channel.addEventListener("message", (event) => {
if (event.data.type === "add") {
// Re-fetch or invalidate cache
}
});
Alternativa: BroadcastChannel + invalidacao
de UI. Pra sync real (server ↔ client),
cobre em background-sync.
count, limit, offset em queries.
// Count
const total = await db.users.count();
// Pagination
const page1 = await db.users
.orderBy("createdAt")
.reverse() // mais recentes primeiro
.offset(0)
.limit(20)
.toArray();
const page2 = await db.users
.orderBy("createdAt")
.reverse()
.offset(20)
.limit(20)
.toArray();
Use offset + limit (mais simples) ou
lastKey (cursor-based, melhor para
dados que mudam durante pagination).
Pra quem quer ir mais alem 🔴
Por que IndexedDB e' async ate pra
abrir. A API nativa usa eventos (nao
promises), e o indexedDB.open() retorna
um IDBOpenDBRequest - nao e' sincrono.
Por que? Porque o browser nao bloqueia
a main thread - abrir DB pode envolver
disk I/O. Mesmo no localStorage (sync),
writes podem bloquear a thread. IndexedDB
sempre async = sempre nao-bloqueia.
Multi-tab e versionamento. Quando
multiplas abas estao abertas e voce faz
db.version(2).upgrade(...), o browser
bloqueia todas as abas ate a migration
terminar. Por isso, mantenha migrations
rapidas e teste em multi-tab antes de
deploy.
Criptografia de DB inteiro (avancado). Pra apps que armazenam dados muito sensiveis (LUKS-style), bibliotecas como ciphered (Dexie wrapper) encriptam o DB inteiro com senha do usuario. Custo: performance (10-50%). Justifica so em apps finance/health.
IndexedDB e SSR. IndexedDB e'
client-only (nao existe no server).
Em SSR (Next.js, Remix), qualquer uso
deve estar em useEffect ou dinamico
(dynamic import). Senao, build quebra.
@types/dexie e TypeScript. Dexie tem
suporte excelente a TypeScript via
Table<T, KeyType>:
import Dexie, { type Table } from "dexie";
interface User {
id?: number;
name: string;
email: string;
}
class MyDB extends Dexie {
users!: Table<User, number>;
constructor() {
super("my-db");
this.version(1).stores({ users: "++id, email" });
}
}
const db = new MyDB();
await db.users.add({ name: "Ana", email: "ana@x.com" });
// TypeScript sabe que add recebe User, e que get(1) retorna User | undefined
Leitura recomendada:
- MDN - IndexedDB API - doc oficial MDN.
- Dexie.js docs - tutorial oficial Dexie, com exemplos.
- web.dev - IndexedDB best practices - patterns avancados.
Dica: o erro mais comum em IndexedDB e' tentar usar a API nativa direto. Em 2026, sempre use Dexie (ou idb). A API nativa existe desde 2010 e nunca foi melhorada - a verbosidade e' proposital (baixo nivel). Dexie expoe o que voce realmente precisa: schema declarativo, promises, queries fluentes. A economia e' brutal: 50 linhas viram 5. Use Dexie.
No proximo no, vamos background sync: a API que ativa retry automatico de requests que falharam offline. Quando o usuario faz POST offline, o SW guarda o request, e quando volta online, re-executa. O usuario nunca perde uma acao.
// Quiz
Por que usar Dexie.js em vez da API nativa do IndexedDB?