IndexedDB mit Claude Code: Lokale Daten sauber umsetzen
IndexedDB mit Claude Code: Schema, Migrationen, Indizes, Transaktionen, Quota, Offline-Sync und Tests.
IndexedDB ist die Browser-Datenbank für Situationen, in denen localStorage zu klein oder zu grob wird. Sie speichert strukturierte Objekte, kann mit Indizes suchen, arbeitet asynchron und unterstützt Transaktionen. Das ist nützlich für Entwürfe, Offline-Warteschlangen, durchsuchbare Caches, Medienmetadaten und lokale Zwischenergebnisse aus AI-Tools.
Die Herausforderung ist nicht die Demo. Die Herausforderung ist ein Schema, das sich später migrieren lässt, kurze Transaktionen, verständliche Quota-Fehler, Verhalten bei mehreren Tabs und eine klare Regel für Konflikte zwischen Offline-Änderungen und Serverdaten. Wenn du Claude Code nur “baue IndexedDB ein” sagst, entsteht leicht Code, der im Happy Path funktioniert, aber bei Upgrade, Storage Pressure oder Sync scheitert.
Dieser Artikel nutzt TypeScript und den leichten Wrapper idb. Die geprüften Primärquellen sind MDN Using IndexedDB, MDN storage quotas and eviction criteria, web.dev Storage for the web, das idb README und die Dexie.js Dokumentation. Für sichere Arbeitsweisen mit Claude Code passen dazu Claude Code Productivity Tips und Claude Code Testing Strategies.
Wann IndexedDB besser ist als localStorage
localStorage ist gut für kleine Strings: Theme, Sprache, zuletzt geöffneter Tab, kurze UI-Einstellung. Es ist einfach, aber synchron, blockiert den Main Thread und hat keine Indizes. Sobald du eine große JSON-Liste speicherst und bei jeder Änderung komplett neu schreibst, ist IndexedDB meist die bessere Richtung.
IndexedDB funktioniert wie eine kleine lokale Datenbank. Ein object store enthält Datensätze. Ein key identifiziert den Datensatz. Ein index erlaubt Suche über ein anderes Feld. Eine transaction bündelt mehrere Schreibvorgänge, damit kein halbfertiger Zustand entsteht. Sie ersetzt nicht die Serverdatenbank, stabilisiert aber Browser-Workflows bei schwachem Netz.
| Workflow | localStorage | IndexedDB |
|---|---|---|
| Theme, UI-Modus, kurze Einstellung | Geeignet | Meist zu viel |
| Langer Entwurf, Formularwiederherstellung | Fragil und blockierend | Geeignet |
| Durchsuchbarer Artikel- oder Produktcache | Wird zu großem JSON | Indizes nutzen |
| Blob, Anhang, Medienstatus | Schlecht geeignet | Praktisch |
| Offline-Aktionsqueue | Reihenfolge und Retry mühsam | Gut mit Transaktionen |
Konkrete Fälle gibt es mehr als drei. Ein CMS speichert Artikelentwürfe mit updatedAt und syncStatus. Ein SaaS-Dashboard cached Produkte, Pläne oder Help-Center-Einträge und liest sie nach Kategorie oder Aktualität. Eine PWA legt Kommentare, Kontaktformulare oder Admin-Änderungen in eine Offline-Queue. Ein Claude-Code-gestütztes Tool speichert größere Analyseergebnisse mit Ablaufzeit, statt bei jedem Seitenwechsel erneut eine API aufzurufen.
Schema, Version und Index zuerst planen
Die erste Frage lautet nicht “was speichern wir?”, sondern “wie lesen wir es wieder?”. Nur per id zu lesen ist simpel. In echten Apps willst du aber “nur dirty drafts”, “älteste Cache-Einträge”, “Jobs nach createdAt” oder “Jobs mit wenigen attempts”. Dafür brauchst du Indizes. Ein späterer Index bedeutet neue DB-Version und Upgrade-Migration.
Ein typischer Fehler ist ein notes store ohne syncStatus index. Danach lädt die App alle Notizen und filtert in JavaScript. Bei 50 Einträgen wirkt das harmlos, bei mehreren tausend bremst es den Start. Ein zweiter Fehler ist eine Version wie 1.1. IndexedDB-Versionen sollten als ganze Zahlen geführt werden: 1, 2, 3.
npm i idb
Das folgende Modul passt in src/lib/local-db.ts einer Client-App. idb hält das IndexedDB-Modell sichtbar. Wenn du reichere Query-Helper, Live Queries oder eine höhere Datenschicht brauchst, kann Dexie sinnvoll sein; hier bleibt der Einstieg bewusst bei idb.
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;
}
Alle Strukturänderungen gehören in upgrade. Wenn ein anderer Tab noch die alte Version offen hält, kann das Öffnen der neuen Version blockieren. blocked sollte eine konkrete Meldung anzeigen, und blocking schließt die aktuelle Verbindung, damit spätere Upgrades nicht hängen.
Entwurf und Queue in einer Transaction speichern
Ein Entwurf und der passende Sync-Job sind eine gemeinsame fachliche Aktion. Wird nur die Notiz gespeichert, aber nicht der Job, meldet die Oberfläche “gespeichert”, obwohl der Server nie informiert wird. Beide Writes gehören in eine readwrite transaction.
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");
}
Warte in einer aktiven IndexedDB-Transaction nicht auf fetch. Wenn keine Datenbankoperation mehr pending ist, kann der Browser die Transaction schließen. Netzwerkarbeit gehört danach oder davor, und das Ergebnis wird in einer neuen Transaction gespeichert.
Quota, Eviction und sichtbare Fehler
IndexedDB ist kein unbegrenzter Speicher. Browser können Writes ablehnen, wenn die Origin über Quote liegt, und best-effort Daten unter Speicherdruck entfernen. Private Modi verhalten sich ebenfalls anders. Plane deshalb Fehlermeldungen, Cache-Aufräumen und Server-Sync ein.
Zwei Produktionsfehler sind besonders häufig. Erstens: QuotaExceededError landet nur in der Konsole, während die UI weiter “gespeichert” zeigt. Zweitens: wichtige Daten wie Zahlung, Vertrag, Bestand oder Supportfall liegen ausschließlich in IndexedDB. Für solche Daten ist IndexedDB Arbeitsfläche oder Queue, nicht endgültige Aufbewahrung.
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(
"Der Browser-Speicher ist voll. Lösche alte Entwürfe.",
);
}
throw error;
}
}
navigator.storage.persist() ist eine Anfrage, keine Garantie. Claude Code sollte daher auch UI-Text, Cleanup-Regeln und Tests ergänzen.
Offline-Queue und Sync-Konflikte
Eine Offline-Queue braucht Reihenfolge, Retry-Grenzen, Duplikatbehandlung und Konfliktregeln. Wird dieselbe Notiz offline auf einem Gerät und online auf einem anderen geändert, darf die alte Offline-Operation nicht blind neuere Serverdaten überschreiben.
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}`);
}
});
});
Bei persönlichen Notizen kann ein Merge-Dialog reichen. Bei Lagerbestand, Buchungen, Rechnungen oder Zahlungsevents sollte der Server mit Version, ETag oder Idempotency Key entscheiden.
export function detectConflict(input: {
local: DraftNote;
remoteVersion: number;
}) {
const { local, remoteVersion } = input;
return (
local.syncStatus === "dirty" &&
remoteVersion > local.baseVersion
);
}
Tests und Grenzen von Mocks
fake-indexeddb macht IndexedDB-ähnliche Tests in Node möglich. Nutze es für Save-Funktionen, Index-Suche, Queue-Insert und Migrationen. Es beweist aber nicht Quota, Eviction, privaten Modus, Multi-Tab-Blocking oder mobile Storage Pressure. Dafür brauchst du echte Browserchecks.
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");
});
Vor dem Release prüfst du: erster Start erstellt die DB, Version 2 ergänzt syncStatus, ein zweiter Tab zeigt den Upgrade-Hinweis, offline gespeicherte Entwürfe überleben Reload, online wird die Queue gesendet, und Storage-Fehler sind verständlich. Bei monetarisierten Seiten bleiben CTA, Produktlinks, Beratungspfade und Analytics unverändert.
Sicherer Claude-Code-Prompt
Der Prompt sollte den lokalen Datenvertrag festlegen. “Mach es offline” ist für IndexedDB zu ungenau.
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
So wird Claude Code zu einem Reviewer der lokalen Datenschicht statt nur zu einem Generator für Browser Storage. Wenn die Seite Umsatz erzeugt, dürfen Offline-Entwürfe, Caches oder Queues keine CTAs, Kauf-Links, Beratungsformulare oder Analytics brechen. Teams können das über Claude Code Training und Beratung standardisieren; Einzelpersonen starten mit dem kostenlosen Cheatsheet und den Claude Code Templates.
Praktischer Prüfbericht
Masa hat dieses Muster in einem kleinen Notiz-Editor getestet. Der größte Gewinn war, syncStatus und updatedAt vor der ersten Implementierung festzulegen. Die erste Version las alle Notizen und filterte in JavaScript; mit mehr Testdaten wurde der Start spürbar langsamer. Nach dem Wechsel zu idb und einer gemeinsamen Transaction für Note und Queue waren Fehlerzustände einfacher zu erklären. fake-indexeddb prüfte Save und Lookup, aber nicht Quota oder Tab-Blocking. Der finale Test lief in Chrome DevTools: Storage löschen, Netzwerk Offline, speichern, neu laden, wieder Online gehen und den Queue-Request bestätigen.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Permission Safety Ladder: Zugriff kontrolliert erweitern
Von read-only zu begrenzten Änderungen, Prüfbefehlen und Deploy-Checks mit klarer Kontrolle.
Claude Code Small PR Proof Pack: kleine Änderungen reviewbar machen
Ein Proof Pack für Claude-Code-PRs: Diff, Checks, öffentliche URL, CTA-Pfad und Rollback.
Claude-Code-Review-Gate vor dem Commit
Vor dem Commit mit Claude Code prüfen: Diff, Build, öffentliche URL, Gumroad-Links, Beratung-CTA, fehlende Tests und fremde Dateien.