IndexedDB con Claude Code: guía práctica para datos locales
Implementa IndexedDB con Claude Code: schema, migraciones, índices, transacciones, quota, sync offline y tests.
IndexedDB es la base de datos del navegador para cuando localStorage ya no alcanza. Permite guardar objetos estructurados, usar índices, escribir de forma asíncrona y agrupar operaciones con transacciones. Si tu app necesita borradores offline, caché consultable, cola de sincronización o resultados grandes generados por una herramienta de IA, IndexedDB suele ser la opción correcta.
La dificultad no está en crear una demo. Lo difícil es diseñar el schema, migrarlo sin romper usuarios existentes, no cerrar una transacción por accidente, manejar QuotaExceededError y decidir qué pasa cuando un cambio offline choca con datos nuevos del servidor. Si pides a Claude Code “añade IndexedDB” sin límites, puede generar algo que funciona en el click feliz pero falla en el primer upgrade o en la primera reconexión.
Esta guía usa TypeScript y el wrapper idb, que conserva los conceptos de IndexedDB pero evita gran parte del código basado en eventos. Las fuentes primarias para este refresh son MDN Using IndexedDB, MDN storage quotas and eviction criteria, web.dev Storage for the web, el README de idb y la documentación de Dexie.js. Para mejorar el hábito de prompts seguros, mira también productividad con Claude Code y estrategias de testing.
Cuándo usar IndexedDB y no localStorage
localStorage sirve para valores pequeños: tema, idioma, último panel abierto, una preferencia corta. Es simple, pero es síncrono, bloquea el hilo principal y no tiene índices. En cuanto guardas una lista grande como JSON y la reescribes entera por cada cambio, estás pagando una deuda que después aparece como lentitud o pérdida de datos.
IndexedDB se parece más a una pequeña base local. Un object store contiene registros. La key identifica cada registro. Un index permite consultar por otra propiedad. Una transaction agrupa lecturas y escrituras para evitar estados a medias. No sustituye a la base de datos del servidor, pero mejora la experiencia cuando hay poca red, formularios largos o datos que se leen muchas veces.
| Flujo | localStorage | IndexedDB |
|---|---|---|
| Tema, modo de UI, preferencia corta | Buena opción | Suele ser excesivo |
| Borrador largo o recuperación de formulario | Frágil y bloqueante | Buena opción |
| Caché consultable de artículos o productos | Termina como JSON gigante | Usa índices |
| Blob, adjuntos, metadatos de media | Mala opción | Práctico |
| Cola de acciones offline | Difícil ordenar y reintentar | Encaja con transacciones |
Hay al menos cuatro casos concretos. Un editor de CMS guarda borradores con updatedAt y syncStatus para recuperar trabajo tras recargar. Un dashboard SaaS cachea productos, planes o artículos de ayuda y los consulta por categoría o fecha. Una PWA guarda comentarios, formularios de contacto o cambios administrativos en una cola offline y los envía al volver la conexión. Una herramienta basada en Claude Code puede guardar resultados de análisis grandes con una fecha de expiración para no repetir llamadas costosas.
Diseña schema, version e índices antes de programar
La primera pregunta no es “qué guardo”, sino “cómo lo voy a leer”. Si solo necesitas id, la estructura es simple. En una app real aparecen consultas como “borradores dirty”, “caché más antigua”, “jobs por createdAt” o “tareas con pocos intentos”. Esas consultas necesitan índices. Añadir un índice después obliga a subir la versión de la base y escribir una migración.
Un fallo típico es crear solo el store notes y luego filtrar todos los registros en JavaScript porque falta el índice syncStatus. Con 50 registros no se nota; con miles, el arranque se vuelve pesado. Otro fallo es usar una versión decimal como 1.1. IndexedDB trabaja con versiones enteras, así que usa 1, 2, 3 y deja cada paso documentado.
npm i idb
Coloca este módulo en src/lib/local-db.ts en una app de Vite, React o Next.js del lado cliente. idb mantiene cerca el modelo de IndexedDB. Si necesitas helpers de consulta más ricos, live queries o una capa de datos de más alto nivel, Dexie también es una opción; aquí usamos idb para mantener el camino inicial pequeño.
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;
}
Todo cambio estructural vive en upgrade. No crees stores o índices desde funciones normales de lectura. Si otra pestaña mantiene abierta una conexión antigua, el upgrade puede quedar bloqueado. blocked debe mostrar un mensaje útil, y blocking evita que tu propia pestaña impida futuras migraciones.
Usa transaction para guardar borrador y cola juntos
Guardar un borrador y encolar su sincronización es una sola acción de negocio. Si se guarda la nota pero falla la cola, el usuario verá “guardado” aunque el servidor nunca reciba el cambio. Usa una transaction readwrite sobre ambos 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");
}
El error frecuente es hacer await fetch() dentro de una transaction abierta. Cuando no quedan operaciones pendientes de IndexedDB, el navegador puede cerrar la transaction. Envía a la red fuera de la transaction y abre otra para marcar el resultado.
Maneja quota, eviction y mensajes de error
IndexedDB no es almacenamiento infinito. El navegador puede rechazar escrituras si el origen supera su cuota o puede eliminar datos best effort bajo presión de espacio. El modo privado también puede cambiar el comportamiento. Diseña con tres ideas: capturar fallos, limpiar cachés viejas y sincronizar al servidor todo lo que sea crítico.
Dos fallos reales son comunes. El primero: QuotaExceededError aparece solo en consola y la interfaz sigue diciendo “guardado”. El segundo: usar IndexedDB como única copia de datos de pago, contrato, inventario o soporte. Para esos datos, IndexedDB es una cola o área de trabajo offline, no el archivo final.
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(
"El almacenamiento del navegador está lleno. Borra borradores antiguos.",
);
}
throw error;
}
}
navigator.storage.persist() es una solicitud, no una garantía absoluta. Pide a Claude Code que también añada copy visible para el usuario, limpieza de caché y tests, no solo un try/catch silencioso.
Cola offline y conflictos de sincronización
Una cola offline necesita orden, reintentos, control de duplicados y regla de conflicto. Si una persona edita una nota offline y la misma nota cambia en otro dispositivo online, reproducir la operación vieja puede sobrescribir datos más nuevos.
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 una nota personal, puedes mostrar versión local y remota y dejar elegir. Para reservas, facturas, inventario o eventos de pago, el servidor debe decidir con version, ETag o idempotency key.
export function detectConflict(input: {
local: DraftNote;
remoteVersion: number;
}) {
const { local, remoteVersion } = input;
return (
local.syncStatus === "dirty" &&
remoteVersion > local.baseVersion
);
}
Testing y límites del mock
fake-indexeddb permite ejecutar pruebas unitarias en Node con una API parecida a IndexedDB. Úsalo para guardar borradores, buscar por índice, insertar en cola y probar migraciones. No lo uses como prueba de quota, eviction, modo privado, bloqueo por pestañas o comportamiento móvil. Eso se valida en 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, verifica que la primera carga crea la DB, la versión 2 añade el índice syncStatus, otra pestaña muestra aviso de upgrade, el borrador offline sobrevive a recarga, la cola se envía al volver online y el error de almacenamiento se entiende. En páginas monetizadas, confirma que CTA, enlaces de producto, consulta y analytics siguen intactos.
Prompt seguro para Claude Code
El prompt debe definir contrato, no solo pedir una función. IndexedDB tiene demasiados bordes para un “hazlo offline”.
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
Con ese nivel de detalle, Claude Code construye una capa local revisable. En un sitio con monetización, la mejora offline no debe romper CTAs, enlaces de compra, formularios de consulta ni eventos de analytics. Para convertirlo en checklist de equipo, revisa formación y consultoría de Claude Code. Para avanzar en solitario, empieza por el cheatsheet gratuito y las plantillas de Claude Code.
Nota de verificación práctica
Masa probó este patrón en un editor pequeño de notas. La mejora más clara fue decidir desde el inicio los índices syncStatus y updatedAt; la primera versión filtraba todo en JavaScript y se volvió pesada al crecer los datos de prueba. Con idb y una transaction para nota + cola, el estado de fallo fue más fácil de explicar. fake-indexeddb cubrió guardado y búsqueda, pero no quota ni bloqueo por pestañas. La comprobación final usó Chrome DevTools para borrar storage, poner red Offline, guardar, recargar, volver Online y confirmar el envío de la cola.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.