IndexedDB avec Claude Code : guide local-first pour débuter
Implémentez IndexedDB avec Claude Code : schema, migrations, index, transactions, quota, offline sync et tests.
IndexedDB est la base de données du navigateur à utiliser quand localStorage devient trop limité. Elle stocke des objets structurés, accepte des index, fonctionne de manière asynchrone et permet de regrouper des écritures dans une transaction. Pour des brouillons hors ligne, une file de synchronisation, un cache consultable ou des résultats lourds générés par un outil IA, c’est souvent le bon choix.
La partie difficile n’est pas de créer une démo. La partie difficile est de concevoir le schema, de le migrer sans casser les utilisateurs existants, de ne pas fermer une transaction par accident, de gérer QuotaExceededError et de décider quoi faire quand une modification offline entre en conflit avec le serveur. Si tu demandes simplement à Claude Code “ajoute IndexedDB”, il peut produire un flux heureux mais oublier les migrations, les erreurs de quota et les tests.
Ce guide utilise TypeScript avec le wrapper léger idb. Les références primaires consultées sont MDN Using IndexedDB, MDN storage quotas and eviction criteria, web.dev Storage for the web, le README idb et la documentation Dexie.js. Pour cadrer les prompts de Claude Code, relis aussi les conseils de productivité Claude Code et les stratégies de test.
Quand IndexedDB remplace localStorage
localStorage convient aux petites chaînes : thème, langue, panneau récemment ouvert, préférence courte. Il est simple mais synchrone, bloque le thread principal, n’a pas d’index et devient vite maladroit quand on réécrit un grand tableau JSON à chaque modification.
IndexedDB ressemble davantage à une petite base locale. Un object store contient les enregistrements. Une key identifie un enregistrement. Un index permet de chercher par un autre champ. Une transaction regroupe plusieurs opérations pour éviter un état à moitié écrit. Ce n’est pas un remplacement de la base serveur, mais un outil pour rendre l’interface robuste quand le réseau est instable.
| Besoin | localStorage | IndexedDB |
|---|---|---|
| Thème, mode UI, petite préférence | Adapté | Souvent excessif |
| Brouillon long, récupération de formulaire | Fragile et bloquant | Adapté |
| Cache d’articles ou produits consultable | Devient un gros JSON | Utilise des index |
| Blob, pièce jointe, état média | Mauvais choix | Pratique |
| File d’actions hors ligne | Ordre et retry difficiles | Adapté aux transactions |
Les cas concrets reviennent souvent. Un CMS ou un éditeur de blog sauvegarde les brouillons avec updatedAt et syncStatus. Un dashboard SaaS cache produits, offres ou articles d’aide et les lit par catégorie ou fraîcheur. Une PWA stocke commentaires, formulaires de contact ou changements d’administration dans une queue offline. Un outil basé sur Claude Code peut garder des résultats d’analyse volumineux avec une date d’expiration au lieu de relancer la même requête coûteuse.
Concevoir schema, version et index avant le code
La première question n’est pas “que stocker ?”, mais “comment le relire ?”. Si l’accès se fait uniquement par id, le design est simple. Dans une app réelle, tu veux souvent “les brouillons dirty”, “les entrées de cache les plus anciennes”, “les jobs par createdAt” ou “les jobs sous trois tentatives”. Ces recherches nécessitent des index. Ajouter un index plus tard demande une nouvelle version et une migration.
Un échec courant consiste à créer seulement notes, puis à charger toutes les notes et filtrer en JavaScript parce que l’index syncStatus n’existe pas. Avec 50 éléments, cela passe. Avec plusieurs milliers, le démarrage ralentit. Autre piège : utiliser une version décimale comme 1.1. Les versions IndexedDB doivent être pensées comme des entiers : 1, 2, 3.
npm i idb
Voici un module client src/lib/local-db.ts prêt à adapter dans Vite, React ou Next.js. idb garde le modèle IndexedDB visible. Si tu as besoin de helpers de requête plus riches, de live queries ou d’une couche plus abstraite, Dexie peut être un bon choix ; ici, on garde idb pour commencer petit.
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;
}
Tout changement de structure doit rester dans upgrade. Ne crée pas d’index depuis les fonctions normales de lecture. Si un autre onglet garde l’ancienne version ouverte, l’upgrade peut rester bloqué. blocked sert à informer l’utilisateur, et blocking ferme la connexion courante pour ne pas bloquer les futures migrations.
Sauvegarder brouillon et queue dans une transaction
Sauvegarder une note et ajouter un job de synchronisation représente une seule action métier. Si la note est écrite mais que la queue échoue, l’interface dit “enregistré” alors que le serveur ne recevra rien. Il faut une transaction readwrite sur les deux 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");
}
Le piège est d’attendre un fetch dans une transaction active. Une transaction peut se fermer lorsque le navigateur ne voit plus de travail IndexedDB en attente. Garde les appels réseau hors de la transaction, puis ouvre une nouvelle transaction pour marquer la réussite ou l’échec.
Quota, eviction et erreurs visibles
IndexedDB n’est pas infini. Le navigateur peut refuser une écriture si le quota de l’origine est dépassé, et les données best effort peuvent être supprimées sous pression de stockage. Le mode privé peut aussi changer les garanties. Il faut donc prévoir les erreurs, nettoyer les caches anciens et synchroniser au serveur ce qui est critique.
Deux erreurs reviennent en production. La première : QuotaExceededError reste dans la console tandis que l’UI affiche “sauvegardé”. La seconde : utiliser IndexedDB comme unique copie d’informations de paiement, contrat, stock ou support. Pour ces données, IndexedDB doit être une zone de travail locale, pas l’archive finale.
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(
"Le stockage du navigateur est plein. Supprimez d'anciens brouillons.",
);
}
throw error;
}
}
persist() est une demande, pas une garantie. Claude Code doit aussi produire le message utilisateur, la stratégie de nettoyage et les tests associés.
Queue offline et conflits de sync
Une queue offline doit définir l’ordre, les retries, les doublons et les conflits. Si une note est modifiée offline sur un ordinateur et online sur un autre, rejouer l’ancienne opération peut écraser une version plus récente.
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}`);
}
});
});
Pour une note personnelle, afficher les versions locale et distante peut suffire. Pour du stock, des réservations, des factures ou des paiements, le serveur doit décider avec version, ETag ou clé d’idempotence.
export function detectConflict(input: {
local: DraftNote;
remoteVersion: number;
}) {
const { local, remoteVersion } = input;
return (
local.syncStatus === "dirty" &&
remoteVersion > local.baseVersion
);
}
Tester avec mock, puis avec navigateur réel
fake-indexeddb donne à Vitest une API proche d’IndexedDB dans Node. C’est utile pour tester la sauvegarde, la recherche par index, l’insertion dans la queue et les migrations. Mais cela ne prouve pas le quota, l’eviction, le mode privé, le blocage multi-onglets ou la pression de stockage mobile.
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");
});
Avant publication, vérifie que le premier lancement crée la DB, que la version 2 ajoute l’index syncStatus, qu’un autre onglet affiche l’avertissement d’upgrade, qu’un brouillon offline survit au reload, que la queue part au retour online et que l’erreur de stockage est lisible. Sur une page monétisée, les CTA, liens produit, liens de consultation et events analytics doivent rester intacts.
Prompt sûr pour Claude Code
Le prompt doit décrire le contrat de données local. “Rends l’app offline” est trop vague pour IndexedDB.
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
Ce prompt pousse Claude Code à produire une couche locale vérifiable. Sur un site monétisé, l’offline ne doit pas casser les CTA, les liens d’achat, les formulaires de consultation ou les analytics. Pour transformer ces critères en pratique d’équipe, consulte la formation et consultation Claude Code. En solo, commence avec le cheatsheet gratuit et les templates Claude Code.
Note de vérification terrain
Masa a testé ce pattern dans un petit éditeur de notes. Le gain principal a été de choisir syncStatus et updatedAt dès le départ. La première version chargeait tout puis filtrait en JavaScript ; elle ralentissait quand les données de test augmentaient. Avec idb et une transaction note + queue, les états d’échec étaient plus faciles à expliquer. fake-indexeddb a validé sauvegarde et recherche, mais pas le quota ni le blocage multi-onglets. Le dernier contrôle a utilisé Chrome DevTools : vider le storage, passer Offline, sauvegarder, recharger, repasser Online et confirmer l’envoi de la queue.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Échelle de sécurité des permissions Claude Code
Passer du read-only aux éditions limitées, preuves et checks de déploiement sans perdre le contrôle.
Claude Code Small PR Proof Pack : rendre les petits changements reviewables
Un pack de preuve pour PR Claude Code : diff, vérifications, URL publique, CTA et rollback.
Gate de review avant commit avec Claude Code
Review avant commit avec Claude Code : diff, build, URL publique, liens Gumroad, CTA consultation, tests manquants et fichiers hors scope.