// apis
Tutorial de IndexedDB: construir una app de notas que funciona sin conexion
Un tutorial de IndexedDB paso a paso. Abrir una base de datos, crear un object store, anadir y leer registros, consultar por indice, eliminar entradas y gestionar las subidas de version, todo en una pequena app de notas que puedes ejecutar en el navegador.
La guia conceptual explica que es IndexedDB y cuando usarla. Este tutorial construye una pequena aplicacion de notas desde cero para que veas cada paso: abrir una base de datos, crear un store, escribir registros, leerlos, consultar por indice y eliminar entradas.
Todo se ejecuta en el navegador. Sin herramientas de build, sin servidor, sin dependencias. Copia cada bloque en un solo archivo HTML y abrelo.
Paso 1: abrir la base de datos
Toda interaccion con IndexedDB comienza con indexedDB.open(). Le pasas un nombre y un numero de version. Si la base de datos no existe todavia, o si tu version es superior a la que hay en disco, el navegador dispara upgradeneeded.
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('notes-app', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('notes')) {
const store = db.createObjectStore('notes', {
keyPath: 'id',
autoIncrement: true,
});
store.createIndex('by_tag', 'tag', { unique: false });
store.createIndex('by_date', 'createdAt', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} Algunos puntos a tener en cuenta. keyPath: 'id' le dice a IndexedDB que propiedad de cada objeto es la clave primaria. autoIncrement: true hace que la base de datos genere esa clave por ti. Las dos llamadas a createIndex te permitiran consultar notas por tag o por fecha mas adelante, sin recorrer todos los registros.
Paso 2: anadir un registro
Escribir en IndexedDB siempre ocurre dentro de una transaccion. Abres una sobre el object store que necesitas, obtienes una referencia a ese store y llamas a add() o put().
async function addNote(db, text, tag) {
return new Promise((resolve, reject) => {
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const record = {
text,
tag: tag || 'general',
createdAt: new Date().toISOString(),
};
const request = store.add(record);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} add() lanza un error si ya existe un registro con la misma clave. put() sobreescribe en su lugar. Para registros nuevos, add() es mas seguro porque detecta duplicados accidentales.
Paso 3: leer todos los registros
Para leer todo lo que hay en un store, abre una transaccion readonly y llama a getAll():
async function getAllNotes(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} Si solo necesitas un registro y conoces su clave, usa store.get(key) en su lugar. Devuelve un solo objeto en vez de un array.
Paso 4: consultar por indice
Los indices permiten encontrar registros sin recorrer todo el store. Para obtener todas las notas con el tag "work":
async function getNotesByTag(db, tag) {
return new Promise((resolve, reject) => {
const tx = db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const index = store.index('by_tag');
const request = index.getAll(tag);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} Tambien puedes usar un key range para consultar un rango de valores. Por ejemplo, para obtener notas creadas despues de una fecha determinada:
const range = IDBKeyRange.lowerBound('2026-01-01T00:00:00.000Z');
const request = store.index('by_date').getAll(range); Los cuatro constructores de rango son lowerBound, upperBound, bound (ambos extremos) y only (coincidencia exacta). Cada uno acepta un booleano opcional para excluir el valor limite.
Paso 5: actualizar un registro
La actualizacion usa put(). Debes incluir la propiedad clave (id en nuestro esquema) para que IndexedDB sepa que registro sobreescribir:
async function updateNote(db, id, newText) {
return new Promise((resolve, reject) => {
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const getReq = store.get(id);
getReq.onsuccess = () => {
const note = getReq.result;
if (!note) { reject(new Error('Not found')); return; }
note.text = newText;
const putReq = store.put(note);
putReq.onsuccess = () => resolve();
putReq.onerror = () => reject(putReq.error);
};
getReq.onerror = () => reject(getReq.error);
});
} Paso 6: eliminar un registro
Llama a store.delete(key) dentro de una transaccion readwrite:
async function deleteNote(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} Para borrar todos los registros de un store, usa store.clear() en su lugar.
Paso 7: gestionar subidas de version
Cuando necesitas anadir un nuevo indice o un nuevo store despues de que los usuarios ya tienen la version 1, incrementa el numero de version y gestiona la migracion en onupgradeneeded:
request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
const store = db.createObjectStore('notes', {
keyPath: 'id',
autoIncrement: true,
});
store.createIndex('by_tag', 'tag', { unique: false });
store.createIndex('by_date', 'createdAt', { unique: false });
}
if (oldVersion < 2) {
const tx = event.target.transaction;
const store = tx.objectStore('notes');
store.createIndex('by_priority', 'priority', { unique: false });
}
}; Comprueba event.oldVersion para ejecutar solo las migraciones que el usuario aun no ha visto. Un usuario que sube desde 0 (sin base de datos) ejecuta ambos bloques. Uno que sube desde 1 ejecuta solo el segundo.
Juntando todo
Aqui tienes un script minimo que ejercita todas las funciones anteriores:
(async () => {
const db = await openDB();
const id = await addNote(db, 'Buy groceries', 'personal');
console.log('Added note with id:', id);
const all = await getAllNotes(db);
console.log('All notes:', all);
const tagged = await getNotesByTag(db, 'personal');
console.log('Personal notes:', tagged);
await updateNote(db, id, 'Buy groceries and cook dinner');
console.log('Updated note', id);
await deleteNote(db, id);
console.log('Deleted note', id);
})(); Abre la consola del navegador para ver la salida. Cada operacion es asincrona, cada escritura pasa por una transaccion, y los datos sobreviven a las recargas de pagina sin ningun servidor.
Errores comunes
| Error | Que ocurre | Solucion |
|---|---|---|
Crear un store fuera de onupgradeneeded | InvalidStateError | Todos los cambios de esquema van en onupgradeneeded |
Usar await dentro de un callback de transaccion | La transaccion se cierra automaticamente antes de que el await se resuelva | Manten todas las operaciones del store sincronas dentro de un mismo tick de transaccion, o abre una nueva transaccion despues del await |
| Olvidar incrementar el numero de version | onupgradeneeded nunca se dispara | Incrementa el entero de version cada vez que cambies el esquema |
| Pasar una cadena donde una clave espera un numero | Registro no encontrado | Respeta el tipo: si autoIncrement genera numeros, consulta con un numero |
IndexedDB es verboso, pero cada pieza tiene un proposito: la version controla tu esquema, las transacciones protegen tus datos, y los indices mantienen las lecturas rapidas. Una vez que estos siete pasos se vuelvan rutina, tienes todo lo necesario para almacenar datos estructurados en el cliente, ya sea para soporte sin conexion, cache o un estado que sobreviva a la sesion.