IndexedDB in JavaScript: guida pratica

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, keyPath o chiave fornita).
  • Indice: struttura per interrogare i dati su un campo diverso dalla chiave primaria.
  • Transazioni: raggruppano operazioni atomiche (readonly o readwrite).
  • 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.onversionchange per 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:

  1. Leggere subito da IndexedDB per mostrare UI reattiva offline.
  2. Sincronizzare in background con la rete (es. Background Sync) e aggiornare lo store.
  3. 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.

Torna su