IndexedDB è un database transazionale orientato agli oggetti integrato nei browser moderni. A differenza di localStorage, è pensato per gestire grandi quantità di dati strutturati, query tramite indici e operazioni offline robuste. È asincrono, transazionale e versionato.
Concetti chiave
- Database: contenitore versionato con uno o più Object Store.
- Object Store: simile a una tabella, ma memorizza oggetti JavaScript.
- Chiave: identificatore univoco (auto-increment,
keyPatho chiave fornita). - Indice: struttura per interrogare i dati su un campo diverso dalla chiave primaria.
- Transazioni: raggruppano operazioni atomiche (
readonlyoreadwrite). - Versione: la creazione/modifica degli schemi avviene nell’evento
onupgradeneeded.
Aprire (o creare) un database
Si usa indexedDB.open(name, version). Se la versione è nuova o il DB non esiste, il browser scatena onupgradeneeded, in cui si definiscono store e indici.
const DB_NAME = "TodoDB";
const DB_VERSION = 3;
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = request.result;
// Creazione store principale
if (!db.objectStoreNames.contains("todos")) {
const store = db.createObjectStore("todos", {
keyPath: "id" // es. "abc123" o un numero
// autoIncrement: true
});
store.createIndex("by_status", "status"); // status: "open" | "done"
store.createIndex("by_dueDate", "dueDate"); // Date ISO string
store.createIndex("by_project", "projectId");
// Indice composto (array): [projectId, dueDate]
store.createIndex("by_project_due", ["projectId", "dueDate"]);
}
// Esempio di store separato per metadati
if (!db.objectStoreNames.contains("meta")) {
db.createObjectStore("meta", { keyPath: "k" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => console.warn("Upgrade bloccato: chiudere vecchie schede.");
});
}
Utility: trasformare le richieste in Promise
L’API nativa è a eventi. Queste utility rendono il codice più lineare con async/await.
function toPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function txDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error);
tx.onerror = () => reject(tx.error);
});
}
Inserire, leggere, aggiornare, eliminare
async function addTodo(db, todo) {
const tx = db.transaction("todos", "readwrite");
const store = tx.objectStore("todos");
store.add(todo); // se esiste stessa chiave -> errore
await txDone(tx);
}
async function putTodo(db, todo) {
const tx = db.transaction("todos", "readwrite");
tx.objectStore("todos").put(todo); // upsert
await txDone(tx);
}
async function getTodo(db, id) {
const tx = db.transaction("todos", "readonly");
const req = tx.objectStore("todos").get(id);
const result = await toPromise(req);
await txDone(tx);
return result;
}
async function deleteTodo(db, id) {
const tx = db.transaction("todos", "readwrite");
tx.objectStore("todos").delete(id);
await txDone(tx);
}
Query con indici
Gli indici permettono ricerche efficienti su campi non primari. È possibile filtrare con IDBKeyRange.
async function getOpenTodos(db) {
const tx = db.transaction("todos", "readonly");
const index = tx.objectStore("todos").index("by_status");
const req = index.getAll("open"); // tutti gli "open"
const result = await toPromise(req);
await txDone(tx);
return result;
}
async function getTodosByProjectBetween(db, projectId, fromISO, toISO) {
const tx = db.transaction("todos", "readonly");
const idx = tx.objectStore("todos").index("by_project_due");
const range = IDBKeyRange.bound([projectId, fromISO], [projectId, toISO]);
const req = idx.getAll(range);
const result = await toPromise(req);
await txDone(tx);
return result;
}
Scorrere con i cursori
I cursori consentono scansioni progressive e paginazione personalizzata.
async function* iterateAllOpen(db) {
const tx = db.transaction("todos", "readonly");
const idx = tx.objectStore("todos").index("by_status");
const range = IDBKeyRange.only("open");
const req = idx.openCursor(range); // direzione di default "next"
let cursor = await toPromise(req);
while (cursor) {
yield cursor.value;
cursor.continue(); // sposta al successivo
cursor = await toPromise(req);
}
await txDone(tx);
}
// Esempio di uso:
(async () => {
const db = await openDB();
for await (const todo of iterateAllOpen(db)) {
console.log(todo);
}
})();
Paginazione semplice
async function getPageByDueDate(db, limit = 20, afterKey) {
const tx = db.transaction("todos", "readonly");
const idx = tx.objectStore("todos").index("by_dueDate");
let range = undefined;
if (afterKey) {
range = IDBKeyRange.lowerBound(afterKey, true); // esclude afterKey
}
const cursorReq = idx.openCursor(range, "next");
const page = [];
let cursor = await toPromise(cursorReq);
while (cursor && page.length < limit) {
page.push(cursor.value);
cursor.continue();
cursor = await toPromise(cursorReq);
}
await txDone(tx);
const nextAfter = cursor ? cursor.key : null;
return { items: page, nextAfter };
}
Operazioni atomiche multi-store
Più store possono partecipare alla stessa transazione per mantenere la coerenza dei dati.
async function createTodoAndBumpCounter(db, todo) {
const tx = db.transaction(["todos", "meta"], "readwrite");
const todos = tx.objectStore("todos");
const meta = tx.objectStore("meta");
todos.add(todo);
const counter = (await toPromise(meta.get("count"))) || { k: "count", v: 0 };
counter.v += 1;
meta.put(counter);
await txDone(tx); // o tutto riesce, o tutto viene annullato
}
Aggiornamenti di schema (migrazioni)
Usa onupgradeneeded per evolvere la struttura tra versioni, senza cancellare dati esistenti.
const request = indexedDB.open("TodoDB", 4);
request.onupgradeneeded = (event) => {
const db = request.result;
const oldVersion = event.oldVersion;
if (oldVersion < 2) {
const store = db.createObjectStore("todos", { keyPath: "id" });
store.createIndex("by_status", "status");
}
if (oldVersion < 3) {
const store = request.transaction.objectStore("todos");
store.createIndex("by_dueDate", "dueDate");
}
if (oldVersion < 4) {
const store = request.transaction.objectStore("todos");
store.createIndex("by_project_due", ["projectId", "dueDate"]);
}
};
Gestione errori e casi particolari
- Quota/Spazio: gli inserimenti possono fallire per spazio insufficiente; prevedi un catch e un piano di fallback.
- Version change: ascolta
db.onversionchangeper chiudere istanze più vecchie. - Blocked: se l’upgrade è bloccato, invita l’utente a chiudere altre schede.
async function init() {
const db = await openDB();
db.onversionchange = () => {
db.close();
alert("Database aggiornato in un'altra scheda. Ricarica la pagina.");
};
try {
await addTodo(db, { id: "t1", title: "Test", status: "open", dueDate: "2025-10-25", projectId: "p1" });
} catch (err) {
console.error("Errore IndexedDB:", err?.name, err?.message);
}
}
Ricerche flessibili con IDBKeyRange
IDBKeyRange.only(x): singolo valore.IDBKeyRange.lowerBound(x, open): da x in su.IDBKeyRange.upperBound(x, open): fino a x.IDBKeyRange.bound(a, b, openA, openB): intervallo.
async function dueBefore(db, iso) {
const tx = db.transaction("todos", "readonly");
const idx = tx.objectStore("todos").index("by_dueDate");
const range = IDBKeyRange.upperBound(iso); // <= iso
const items = await toPromise(idx.getAll(range));
await txDone(tx);
return items;
}
Import/Export (backup)
Non esiste un dump nativo, ma puoi serializzare i dati e salvarli come JSON.
async function exportTodos(db) {
const tx = db.transaction("todos", "readonly");
const all = await toPromise(tx.objectStore("todos").getAll());
await txDone(tx);
return JSON.stringify(all);
}
async function importTodos(db, json) {
const arr = JSON.parse(json);
const tx = db.transaction("todos", "readwrite");
const store = tx.objectStore("todos");
for (const item of arr) store.put(item);
await txDone(tx);
}
Integrazione con Service Worker e offline-first
IndexedDB è ideale come cache applicativa e dati sincronizzati. Una strategia comune:
- Leggere subito da IndexedDB per mostrare UI reattiva offline.
- Sincronizzare in background con la rete (es. Background Sync) e aggiornare lo store.
- Invalidare selettivamente i dati tramite versioni o timestamp nel tuo Object Store.
Confronto rapido
- localStorage: sincrono, chiave/valore stringa, piccolo. Non adatto per molti dati.
- Cache Storage: pensato per risorse HTTP; non per dati arbitrari.
- IndexedDB: dati strutturati, query, transazioni, grandi volumi.
Prestazioni e buone pratiche
- Usa indici per le query ripetute e definiscili in
onupgradeneeded. - Apri una sola connessione a DB e riutilizzala.
- Raggruppa scritture correlate in un’unica transazione
readwrite. - Evita oggetti profondi; preferisci strutture piatte e campi indicizzabili.
- Per elenchi molto grandi, usa cursori invece di
getAll().
Cancellare uno store o l’intero database
// Eliminare un singolo store durante un upgrade
request.onupgradeneeded = () => {
const db = request.result;
if (db.objectStoreNames.contains("oldStore")) {
db.deleteObjectStore("oldStore");
}
};
// Eliminare completamente il DB
function deleteDatabase(name = DB_NAME) {
return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => console.warn("Delete bloccato: chiudere schede aperte.");
});
}
Esempio completo: TODO app minimale
<!-- HTML minimale -->
<button id="add">Aggiungi TODO</button>
<ul id="list"></ul>
<script type="module">
import { start } from "./app.js";
start();
</script>
// app.js
import { openDB, toPromise, txDone } from "./db.js";
export async function start() {
const db = await openDB();
document.getElementById("add").addEventListener("click", async () => {
const todo = {
id: crypto.randomUUID(),
title: "Nuovo TODO",
status: "open",
dueDate: new Date(Date.now() + 86400000).toISOString(),
projectId: "default"
};
const tx = db.transaction("todos", "readwrite");
tx.objectStore("todos").add(todo);
await txDone(tx);
render(db);
});
render(db);
}
async function render(db) {
const list = document.getElementById("list");
list.innerHTML = "";
const tx = db.transaction("todos", "readonly");
const items = await toPromise(tx.objectStore("todos").getAll());
await txDone(tx);
for (const t of items) {
const li = document.createElement("li");
li.textContent = `${t.title} [${t.status}]`;
list.appendChild(li);
}
}
// db.js
export const DB_NAME = "TodoDB";
export const DB_VERSION = 3;
export function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains("todos")) {
const store = db.createObjectStore("todos", { keyPath: "id" });
store.createIndex("by_status", "status");
store.createIndex("by_dueDate", "dueDate");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export function toPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export function txDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onabort = tx.onerror = () => reject(tx.error);
});
}
Domande frequenti
- Serve un polyfill? In genere no sui browser moderni; per un’API promise-based valuta piccole utility o librerie dedicate.
- È sicuro? IndexedDB è isolato per origine e soggetto a quota. I dati non sono crittografati di default.
- Quanto posso salvare? Dipende dal browser e dallo spazio libero; chiedi persistenza con
navigator.storage.persist()dove possibile.
Conclusione
IndexedDB offre un’infrastruttura potente per app web offline-first e data-intensive. Progetta con cura lo schema, definisci indici utili, usa transazioni in modo coerente e incapsula l’API a eventi con piccole utility per scrivere codice pulito e affidabile.