Mini ORM JavaScript pour IndexedDB : API fluide CRUD, transactions, migrations, quotas navigateurs, pattern offline-first et comparatif Dexie/idb.
Introduction à IndexedDB
IndexedDB est une base de données NoSQL intégrée aux navigateurs modernes. Elle permet de stocker de grandes quantités de données structurées localement, avec des index pour des recherches rapides.
Contrairement à localStorage qui est limité à ~5-10 Mo de données textuelles, IndexedDB peut stocker des centaines de mégaoctets voire plusieurs gigaoctets selon le navigateur.
- Base de données orientée objet (objets JavaScript)
- Support des transactions ACID
- API asynchrone (basée sur des événements)
- Stockage hors-ligne persistant
- Index multiples pour recherches efficaces
Pourquoi utiliser IndexedDB ?
Cas d'usage typiques
- PWA et applications offline : Synchroniser les données quand la connexion revient
- Cache applicatif : Améliorer les performances en évitant des appels API répétés
- Applications riches en données : Tableaux de bord, éditeurs, gestionnaires de fichiers
- Formulaires complexes : Sauvegarder automatiquement les brouillons
- Jeux web : Sauvegardes locales, assets, scores
Comparaison avec d'autres solutions
| Solution | Capacité | Type de données | Performance |
|---|---|---|---|
| localStorage | ~5-10 Mo | String uniquement | Synchrone (bloquant) |
| sessionStorage | ~5-10 Mo | String uniquement | Synchrone (bloquant) |
| IndexedDB | 50 Mo - 1+ Go | Objets, Blob, ArrayBuffer | Asynchrone (non-bloquant) |
| Cache API | Variable | Requêtes/Réponses HTTP | Asynchrone |
Limites de l'API native
L'API IndexedDB native est puissante mais très verbeuse et complexe à utiliser. Voici un exemple pour simplement ajouter un objet :
// API native - trop verbeux !
const request = indexedDB.open('myDB', 1);
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
const addRequest = store.add({ id: 1, name: 'Alice' });
addRequest.onsuccess = () => {
console.log('Utilisateur ajouté');
};
addRequest.onerror = () => {
console.error('Erreur');
};
};
request.onerror = () => {
console.error('Erreur ouverture DB');
};
Pour une simple opération CRUD, il faut gérer :
- Ouverture de la base de données
- Création de transactions
- Accès aux object stores
- Gestion des callbacks success/error
- Gestion du versioning
Architecture du mini ORM
Notre ORM va fournir une API simple et chainable :
// API cible - simple et moderne
const db = new MiniORM('myApp', 1);
// CRUD en une ligne
await db.table('users').insert({ id: 1, name: 'Alice' });
const user = await db.table('users').find(1);
await db.table('users').update(1, { name: 'Bob' });
await db.table('users').delete(1);
// Requêtes avancées
const admins = await db.table('users')
.where('role', 'admin')
.orderBy('name')
.limit(10)
.getAll();
Classes principales
- MiniORM : Classe principale, gestion de la connexion
- Table : Représente une table (object store)
- Query : Builder de requêtes avec méthodes chainables
Implémentation complète
Classe MiniORM
class MiniORM {
constructor(dbName, version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
this.schemas = new Map();
}
// Définir le schéma d'une table
defineTable(tableName, options = {}) {
this.schemas.set(tableName, {
keyPath: options.keyPath || 'id',
autoIncrement: options.autoIncrement !== false,
indexes: options.indexes || []
});
return this;
}
// Ouvrir la connexion
async connect() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Créer les object stores selon les schémas
this.schemas.forEach((schema, tableName) => {
if (!db.objectStoreNames.contains(tableName)) {
const store = db.createObjectStore(tableName, {
keyPath: schema.keyPath,
autoIncrement: schema.autoIncrement
});
// Créer les index
schema.indexes.forEach(index => {
store.createIndex(
index.name,
index.keyPath,
{ unique: index.unique || false }
);
});
}
});
};
});
}
// Accéder à une table
table(tableName) {
if (!this.db) {
throw new Error('Database not connected. Call connect() first.');
}
return new Table(this.db, tableName);
}
// Fermer la connexion
close() {
if (this.db) {
this.db.close();
this.db = null;
}
}
}
Classe Table
class Table {
constructor(db, tableName) {
this.db = db;
this.tableName = tableName;
}
// INSERT
async insert(data) {
return this._transaction('readwrite', (store) => {
return store.add(data);
});
}
// FIND (par clé primaire)
async find(key) {
return this._transaction('readonly', (store) => {
return store.get(key);
});
}
// UPDATE
async update(key, data) {
const existing = await this.find(key);
if (!existing) {
throw new Error(`Record with key ${key} not found`);
}
const updated = { ...existing, ...data };
return this._transaction('readwrite', (store) => {
return store.put(updated);
});
}
// DELETE
async delete(key) {
return this._transaction('readwrite', (store) => {
return store.delete(key);
});
}
// GET ALL
async getAll() {
return this._transaction('readonly', (store) => {
return store.getAll();
});
}
// COUNT
async count() {
return this._transaction('readonly', (store) => {
return store.count();
});
}
// CLEAR (vider la table)
async clear() {
return this._transaction('readwrite', (store) => {
return store.clear();
});
}
// WHERE (requête par index)
where(indexName, value) {
return new Query(this.db, this.tableName, indexName, value);
}
// Méthode privée pour gérer les transactions
_transaction(mode, callback) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.tableName], mode);
const store = transaction.objectStore(this.tableName);
const request = callback(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
Classe Query (requêtes avancées)
class Query {
constructor(db, tableName, indexName, value) {
this.db = db;
this.tableName = tableName;
this.indexName = indexName;
this.value = value;
this._limit = null;
this._orderDirection = 'next';
}
// Limiter les résultats
limit(count) {
this._limit = count;
return this;
}
// Ordre Descendant
orderByDesc() {
this._orderDirection = 'prev';
return this;
}
// Exécuter la requête
async getAll() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.tableName], 'readonly');
const store = transaction.objectStore(this.tableName);
const index = store.index(this.indexName);
const request = index.openCursor(
IDBKeyRange.only(this.value),
this._orderDirection
);
const results = [];
let count = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor && (!this._limit || count < this._limit)) {
results.push(cursor.value);
count++;
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
// Premier résultat uniquement
async first() {
const results = await this.limit(1).getAll();
return results[0] || null;
}
}
Utilisation : opérations CRUD
Configuration initiale
// Initialiser le mini ORM
const db = new MiniORM('monApp', 1);
// Définir les tables
db.defineTable('users', {
keyPath: 'id',
autoIncrement: true,
indexes: [
{ name: 'email', keyPath: 'email', unique: true },
{ name: 'role', keyPath: 'role' }
]
});
db.defineTable('posts', {
keyPath: 'id',
autoIncrement: true,
indexes: [
{ name: 'userId', keyPath: 'userId' },
{ name: 'status', keyPath: 'status' }
]
});
// Connexion
await db.connect();
CREATE - Insérer des données
// Insérer un utilisateur
const userId = await db.table('users').insert({
name: 'Alice Dupont',
email: 'alice@example.com',
role: 'admin',
createdAt: new Date()
});
console.log('User ID:', userId);
// Insérer plusieurs posts
const posts = [
{ userId: 1, title: 'Premier post', status: 'published' },
{ userId: 1, title: 'Brouillon', status: 'draft' }
];
for (const post of posts) {
await db.table('posts').insert(post);
}
READ - Lire des données
// Récupérer par ID
const user = await db.table('users').find(1);
console.log('User:', user);
// Récupérer tous les utilisateurs
const allUsers = await db.table('users').getAll();
console.log('Total users:', allUsers.length);
// Requête avec index
const admins = await db.table('users')
.where('role', 'admin')
.getAll();
// Requête avec limite
const recentPosts = await db.table('posts')
.where('status', 'published')
.orderByDesc()
.limit(10)
.getAll();
// Compter les entrées
const userCount = await db.table('users').count();
console.log('Nombre d\'utilisateurs:', userCount);
UPDATE - Mettre à jour
// Mise à jour partielle
await db.table('users').update(1, {
name: 'Alice Martin',
updatedAt: new Date()
});
// Mise à jour complète (PUT)
const user = await db.table('users').find(1);
user.lastLoginAt = new Date();
await db.table('users').update(1, user);
DELETE - Supprimer
// Supprimer un utilisateur
await db.table('users').delete(1);
// Vider toute la table
await db.table('posts').clear();
// Fermer la connexion
db.close();
Transactions et versioning
Transactions manuelles
Pour des opérations complexes nécessitant l'atomicité :
class MiniORM {
// ... code précédent ...
// Transaction multi-tables
async transaction(tableNames, mode, callback) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(tableNames, mode);
const stores = tableNames.map(name => tx.objectStore(name));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
try {
callback(stores);
} catch (error) {
reject(error);
}
});
}
}
// Utilisation : transférer un post d'un user à un autre
await db.transaction(['users', 'posts'], 'readwrite', ([usersStore, postsStore]) => {
const getUserReq = usersStore.get(oldUserId);
getUserReq.onsuccess = () => {
const user = getUserReq.result;
user.postCount--;
usersStore.put(user);
};
const getPostReq = postsStore.get(postId);
getPostReq.onsuccess = () => {
const post = getPostReq.result;
post.userId = newUserId;
postsStore.put(post);
};
});
Gestion du versioning
Migrer la structure de la base lors des montées de version :
const db = new MiniORM('monApp', 2); // Version 2
db.defineTable('users', {
keyPath: 'id',
autoIncrement: true,
indexes: [
{ name: 'email', keyPath: 'email', unique: true },
{ name: 'role', keyPath: 'role' },
{ name: 'premium', keyPath: 'premium' } // Nouvel index v2
]
});
// Migration automatique lors du onupgradeneeded
await db.connect();
// Migrer les données existantes si nécessaire
const users = await db.table('users').getAll();
for (const user of users) {
if (!user.premium) {
await db.table('users').update(user.id, { premium: false });
}
}
Exemples pratiques
Cache d'API avec expiration
class CacheManager {
constructor(db, expirationMs = 3600000) { // 1h par défaut
this.db = db;
this.expirationMs = expirationMs;
}
async get(key) {
const cached = await this.db.table('cache').find(key);
if (!cached) return null;
// Vérifier l'expiration
if (Date.now() - cached.timestamp > this.expirationMs) {
await this.db.table('cache').delete(key);
return null;
}
return cached.data;
}
async set(key, data) {
await this.db.table('cache').insert({
id: key,
data: data,
timestamp: Date.now()
});
}
}
// Utilisation
const cache = new CacheManager(db);
async function fetchUsers() {
const cached = await cache.get('users');
if (cached) return cached;
const users = await fetch('/api/users').then(r => r.json());
await cache.set('users', users);
return users;
}
Sauvegarde auto de formulaire
class FormAutosave {
constructor(db, formId) {
this.db = db;
this.formId = formId;
this.debounceTimer = null;
}
// Sauvegarder avec debounce
save(formData) {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => {
await this.db.table('formDrafts').insert({
id: this.formId,
data: formData,
savedAt: new Date()
});
console.log('Draft saved');
}, 1000);
}
// Restaurer
async restore() {
const draft = await this.db.table('formDrafts').find(this.formId);
return draft ? draft.data : null;
}
// Nettoyer après soumission
async clean() {
await this.db.table('formDrafts').delete(this.formId);
}
}
// Utilisation
const formSaver = new FormAutosave(db, 'contact-form');
document.getElementById('myForm').addEventListener('input', (e) => {
const formData = new FormData(e.target.form);
const data = Object.fromEntries(formData);
formSaver.save(data);
});
// Restaurer au chargement
window.addEventListener('DOMContentLoaded', async () => {
const draft = await formSaver.restore();
if (draft && confirm('Restaurer le brouillon ?')) {
// Remplir le formulaire avec les données sauvegardées
Object.entries(draft).forEach(([key, value]) => {
const input = document.querySelector(`[name="${key}"]`);
if (input) input.value = value;
});
}
});
Alternatives : Dexie.js et autres
Si vous avez besoin de fonctionnalités avancées, considérez ces bibliothèques :
Dexie.js (recommandé)
La bibliothèque la plus populaire pour IndexedDB, avec une API moderne et des fonctionnalités riches.
// npm install dexie
import Dexie from 'dexie';
const db = new Dexie('monApp');
db.version(1).stores({
users: '++id, email, role',
posts: '++id, userId, status'
});
// CRUD simplifié
await db.users.add({ name: 'Alice', email: 'alice@example.com' });
const user = await db.users.get(1);
await db.users.where('role').equals('admin').toArray();
await db.users.update(1, { name: 'Bob' });
await db.users.delete(1);
Autres alternatives
| Bibliothèque | Taille | Points forts |
|---|---|---|
| Dexie.js | ~20 KB | API moderne, TypeScript, observables, hooks |
| idb | ~1 KB | Wrapper minimal basé sur Promises |
| localForage | ~10 KB | API simple type localStorage, fallback auto |
| PouchDB | ~145 KB | Compatible CouchDB, sync multi-device |
Bonnes pratiques
- Versionning : Incrémentez la version à chaque changement de schéma
- Index pertinents : Créez des index sur les champs utilisés dans les requêtes
- Gestion d'erreurs : Toujours wrapper les opérations dans try/catch
- Fermer les connexions : Appelez
db.close()quand terminé - Limiter les transactions : Évitez les transactions longues (bloquent l'accès)
- Pas de données sensibles : IndexedDB n'est pas chiffré (visible DevTools)
- Quota de stockage : Vérifier
navigator.storage.estimate() - Nettoyage régulier : Supprimer les données expirées périodiquement
- Backup important : Ne pas considérer IndexedDB comme stockage permanent
Vérifier le quota disponible
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { quota, usage } = await navigator.storage.estimate();
console.log(`Utilisé: ${(usage / 1024 / 1024).toFixed(2)} MB`);
console.log(`Total: ${(quota / 1024 / 1024).toFixed(2)} MB`);
console.log(`Disponible: ${((quota - usage) / 1024 / 1024).toFixed(2)} MB`);
}
Gestion des erreurs complète
try {
await db.connect();
const user = await db.table('users').find(1);
console.log('User:', user);
} catch (error) {
if (error.name === 'QuotaExceededError') {
console.error('Quota de stockage dépassé');
// Nettoyer les anciennes données
} else if (error.name === 'VersionError') {
console.error('Conflit de version de base de données');
} else {
console.error('Erreur IndexedDB:', error);
}
} finally {
db.close();
}
Pourquoi IndexedDB plutôt que les alternatives ?
Avant de choisir IndexedDB, il faut comprendre où elle se positionne dans le
paysage du stockage navigateur. Les développeurs débutants confondent souvent
localStorage, sessionStorage, IndexedDB et
les cookies. Voici la grille de décision claire.
- localStorage / sessionStorage — clé-valeur, capacité ~5 Mo, synchrone (bloque le thread principal), stockage uniquement de strings. Idéal pour les préférences UI légères : thème, langue, état d'onglet sélectionné. À éviter pour tout ce qui dépasse quelques kilo-octets.
- Cookies — limité à 4 ko, envoyé à chaque requête HTTP (overhead réseau), expirable. Réservé aux tokens d'authentification et aux préférences serveur. À ne plus utiliser pour du stockage applicatif depuis 2015.
-
Cache API — stocke des objets
Responseentiers (HTML, CSS, JS, JSON). Géré par les Service Workers pour les stratégies offline d'assets. Pas adapté aux données structurées modifiables. - IndexedDB — base NoSQL transactionnelle complète, capacité de plusieurs gigaoctets, indices, requêtes, asynchrone. Le seul choix viable pour des données structurées avec lecture/écriture fréquente côté client.
- OPFS (Origin Private File System) — nouveau venu depuis 2023, système de fichiers privé par origine, idéal pour les blobs lourds (photos, vidéos, fichiers utilisateur). Complémentaire à IndexedDB plutôt que concurrent. Supporté par Chrome, Edge, Safari, Firefox depuis 2024.
Pour la majorité des applications, le bon mix en 2026 est : localStorage pour les préférences UI (50 octets), IndexedDB pour les données métier (jusqu'à plusieurs gigaoctets), Cache API pour les assets statiques, OPFS pour les fichiers utilisateur volumineux. Chaque outil a son créneau et ils ne s'excluent pas mutuellement.
Limites de stockage et quotas par navigateur
Comprendre les quotas est essentiel avant de pousser de grosses quantités de données en IndexedDB. Chaque navigateur applique sa propre politique, et celle-ci évolue régulièrement. En 2026, voici l'état du marché.
| Navigateur | Quota par origine | Eviction | Persistent storage |
|---|---|---|---|
| Chrome / Edge | 60 % du disque | LRU global au système | Supportée (navigator.storage.persist) |
| Firefox | 10 % du disque (max 50 Go) | LRU par origine | Supportée |
| Safari macOS | ~1 Go par origine | Après 7 jours d'inactivité | Partielle (iOS 17+) |
| Safari iOS | ~500 Mo par origine | Très agressive (PWA exclues) | Partielle |
Demander un stockage persistant
// Demande à l'utilisateur de garantir la persistance des données
if (navigator.storage && navigator.storage.persist) {
const isPersistent = await navigator.storage.persist();
console.log(`Stockage persistant : ${isPersistent ? 'accordé' : 'refusé'}`);
}
// Vérifier l'usage actuel et le quota
const { usage, quota } = await navigator.storage.estimate();
console.log(`Usage : ${(usage / 1024 / 1024).toFixed(2)} Mo`);
console.log(`Quota : ${(quota / 1024 / 1024).toFixed(2)} Mo`);
console.log(`Reste : ${(((quota - usage) / quota) * 100).toFixed(1)} %`);
Gestion gracieuse du QuotaExceededError
Quand vous écrivez en IndexedDB et que le quota est dépassé, une exception
QuotaExceededError est levée. Plutôt que de planter, votre app doit
gérer ce cas en supprimant les anciennes données, en demandant à l'utilisateur
d'autoriser le stockage persistant, ou en proposant une synchronisation
immédiate avec le serveur pour libérer de la place locale.
Pattern offline-first avec synchronisation
Le vrai pouvoir d'IndexedDB se révèle quand on l'utilise comme couche de persistance locale pour une application offline-first. Les utilisateurs modifient leurs données même sans réseau, l'app stocke tout localement, puis synchronise au serveur dès que la connexion revient. Cette architecture est la base technique des PWA modernes (Gmail offline, Notion, Linear, Figma).
Cette architecture demande une discipline supplémentaire par rapport à un modèle « tout serveur » : il faut gérer la cohérence entre l'état local et l'état distant, penser aux conflits possibles, et concevoir une UX qui montre clairement à l'utilisateur quand ses changements sont en attente de synchronisation. Le retour sur investissement vaut largement l'effort : l'application devient utilisable même dans un avion sans wifi, dans un métro, ou sur un réseau 3G instable. C'est ce qui distingue les apps « modernes » des sites web traditionnels qui s'arrêtent dès que la connexion baisse.
Architecture en 3 couches
- Couche données locale : IndexedDB stocke les entités utilisateur (notes, todos, messages) avec un statut local (
synced,pending,conflict). - Couche mutations : un store dédié
outboxmémorise les opérations à envoyer au serveur. Chaque mutation a un id local, un timestamp et un type (create/update/delete). - Couche synchronisation : un Service Worker écoute les changements de connectivité (
online/offline) et déclenche le flush de l'outbox dès le retour réseau.
Implémentation simplifiée
// orm.outbox.ts — queue de mutations
class OutboxService {
constructor(private db: MiniORM) {}
async enqueue(operation: 'create' | 'update' | 'delete', table: string, data: unknown) {
await this.db.table('outbox').add({
id: crypto.randomUUID(),
operation,
table,
data,
createdAt: Date.now(),
status: 'pending',
});
}
async flush() {
const pending = await this.db.table('outbox').where('status', 'pending').all();
for (const mutation of pending) {
try {
await fetch(`/api/${mutation.table}`, {
method: mutation.operation === 'create' ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mutation.data),
});
await this.db.table('outbox').update(mutation.id, { status: 'synced' });
} catch (err) {
console.warn(`Sync échouée pour ${mutation.id}, retry plus tard`);
}
}
}
}
// Initialisation — flush à chaque retour online
window.addEventListener('online', () => outbox.flush());
Résolution de conflits
Quand le serveur a des données plus récentes que la version locale, vous devez choisir une stratégie de résolution. Trois approches courantes : Last Write Wins (le timestamp le plus récent gagne, simple mais perd des données), Manual Merge (l'utilisateur résout les conflits via une UI dédiée, robuste mais complexe), CRDT (Conflict-free Replicated Data Types via Yjs ou Automerge — fusion automatique mathématiquement correcte, parfait pour le collaboratif temps réel type Figma).
Dans la pratique, le choix dépend du domaine métier. Pour une app de prise de notes personnelles, Last Write Wins suffit largement — un seul utilisateur édite ses propres données, les conflits sont rares. Pour un éditeur collaboratif temps réel (Google Docs, Notion, Figma), les CRDT sont incontournables — ils garantissent que deux utilisateurs qui éditent simultanément verront le même résultat final, sans perte d'information. Pour les apps métier avec validation humaine (CRM, gestion de stock), le Manual Merge est plus sûr.
Un piège fréquent dans les apps offline-first : votre client a une version du
schéma de données, le serveur évolue, le client lui aussi, mais pas au même
rythme. Solution : versionnez vos schémas côté serveur (API /v1/users,
/v2/users) et stockez la version locale en IndexedDB. À chaque sync,
vérifiez la compatibilité.
Comparaison avec Dexie.js et idb
Le mini-ORM présenté dans cet article est pédagogique : 200 lignes qui montrent comment fonctionne IndexedDB sous le capot. Pour la production, deux libs éprouvées sont préférables.
| Critère | Notre mini-ORM | Dexie.js | idb (Jake Archibald) |
|---|---|---|---|
| Taille gzipped | ~3 ko | ~22 ko | ~1.5 ko |
| API | Promise + chainable | Promise + observables | Promise minimaliste |
| Migrations | Manuel | Déclaratif db.version().stores() | Manuel |
| Range queries | Basique | Complète (.where.between) | Native (verbeuse) |
| Observabilité (live queries) | Non | Oui (liveQuery) | Non |
| Support TypeScript | Partiel | Complet (génériques) | Complet |
| Maintenance | Vous | Active (David Fahlander) | Active |
Recommandation 2026 : sur un projet sérieux qui dépend de IndexedDB, utilisez Dexie.js. L'investissement de 22 ko est largement compensé par les features avancées (live queries qui se mettent à jour quand les données changent, syntaxe SQL-like, support TypeScript impeccable). Pour les apps qui veulent une dépendance minimale, idb de Jake Archibald reste un excellent choix — c'est ce que recommande la documentation officielle MDN.
Mini-projet appliqué — PWA Todo offline-first avec sync
Pour matérialiser tous les patterns vus dans un projet complet, voici une app Todo PWA offline-first avec IndexedDB + Service Worker + outbox + résolution de conflits Last-Write-Wins. Le code que vous pouvez déployer demain et qui couvre 100 % du parcours utilisateur : création, édition, suppression, sync auto.
1. Schéma de données + ID locaux distincts des IDs serveur
Pour comprendre la structure du DTO et les patterns de typage associés, voir le guide des utility types.
// Modèle de données
interface Todo {
localId: string; // UUID généré côté client
serverId: string | null; // null tant que non synced
title: string;
done: boolean;
createdAt: number;
updatedAt: number;
syncStatus: 'synced' | 'pending' | 'conflict';
version: number; // incrémenté à chaque update local
}
interface OutboxEntry {
id: string;
operation: 'create' | 'update' | 'delete';
todoLocalId: string;
payload: Partial<Todo>;
enqueuedAt: number;
retries: number;
}
// Initialisation de la DB
const db = await openDB('todos-app', 1, {
upgrade(db) {
const todos = db.createObjectStore('todos', { keyPath: 'localId' });
todos.createIndex('syncStatus', 'syncStatus');
todos.createIndex('serverId', 'serverId');
db.createObjectStore('outbox', { keyPath: 'id' });
},
});
2. Service Todo — opérations CRUD locales avec écriture optimiste
class TodoService {
constructor(private db) {}
async create(title) {
const todo = {
localId: crypto.randomUUID(),
serverId: null,
title,
done: false,
createdAt: Date.now(),
updatedAt: Date.now(),
syncStatus: 'pending',
version: 1,
};
// Transaction atomique : écrit dans todos ET outbox
const tx = this.db.transaction(['todos', 'outbox'], 'readwrite');
await tx.objectStore('todos').add(todo);
await tx.objectStore('outbox').add({
id: crypto.randomUUID(),
operation: 'create',
todoLocalId: todo.localId,
payload: todo,
enqueuedAt: Date.now(),
retries: 0,
});
await tx.done;
// Déclencher la sync si online
if (navigator.onLine) syncService.flush();
return todo;
}
async toggle(localId) {
const todo = await this.db.get('todos', localId);
if (!todo) return;
const updated = {
...todo,
done: !todo.done,
updatedAt: Date.now(),
syncStatus: 'pending',
version: todo.version + 1,
};
const tx = this.db.transaction(['todos', 'outbox'], 'readwrite');
await tx.objectStore('todos').put(updated);
await tx.objectStore('outbox').add({
id: crypto.randomUUID(),
operation: 'update',
todoLocalId: localId,
payload: { done: updated.done, version: updated.version, updatedAt: updated.updatedAt },
enqueuedAt: Date.now(),
retries: 0,
});
await tx.done;
if (navigator.onLine) syncService.flush();
}
async list() {
return this.db.getAll('todos');
}
}
3. SyncService — flush de l'outbox avec retry exponentiel
class SyncService {
constructor(private db) {}
private flushing = false;
async flush() {
if (this.flushing) return;
this.flushing = true;
try {
const entries = await this.db.getAll('outbox');
// Trier par enqueuedAt pour préserver l'ordre causal
entries.sort((a, b) => a.enqueuedAt - b.enqueuedAt);
for (const entry of entries) {
try {
await this.processEntry(entry);
await this.db.delete('outbox', entry.id);
} catch (e) {
// Backoff exponentiel jusqu'à 5 retries
const updated = { ...entry, retries: entry.retries + 1 };
if (updated.retries >= 5) {
// Marquer le todo en conflit pour résolution manuelle
await this.markConflict(entry.todoLocalId);
await this.db.delete('outbox', entry.id);
} else {
await this.db.put('outbox', updated);
// Pause avant le prochain flush
await new Promise(r => setTimeout(r, 1000 * 2 ** updated.retries));
}
}
}
} finally {
this.flushing = false;
}
}
private async processEntry(entry) {
switch (entry.operation) {
case 'create': return this.createOnServer(entry);
case 'update': return this.updateOnServer(entry);
case 'delete': return this.deleteOnServer(entry);
}
}
private async createOnServer(entry) {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry.payload),
});
if (!res.ok) throw new Error(`POST failed: ${res.status}`);
const serverTodo = await res.json();
// Lier l'id local à l'id serveur
const local = await this.db.get('todos', entry.todoLocalId);
await this.db.put('todos', {
...local,
serverId: serverTodo.id,
syncStatus: 'synced',
});
}
private async updateOnServer(entry) {
const local = await this.db.get('todos', entry.todoLocalId);
if (!local?.serverId) {
// Le create initial n'a pas encore été synced — skip pour l'instant
throw new Error('Server ID not yet assigned');
}
const res = await fetch(`/api/todos/${local.serverId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...entry.payload, expectedVersion: local.version - 1 }),
});
if (res.status === 409) {
// Conflit : version serveur plus récente → marquer pour résolution
await this.resolveConflict(entry.todoLocalId, await res.json());
return;
}
if (!res.ok) throw new Error(`PATCH failed: ${res.status}`);
await this.db.put('todos', { ...local, syncStatus: 'synced' });
}
}
4. Résolution de conflits — stratégie Last Write Wins par défaut
private async resolveConflict(localId, serverTodo) {
const local = await this.db.get('todos', localId);
if (!local) return;
// Stratégie LWW : compare les timestamps
if (serverTodo.updatedAt > local.updatedAt) {
// Serveur gagne — écraser les données locales
await this.db.put('todos', {
...local,
...serverTodo,
syncStatus: 'synced',
version: serverTodo.version,
});
} else {
// Local gagne — re-pousser au serveur avec force
await fetch(`/api/todos/${local.serverId}/force`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(local),
});
await this.db.put('todos', { ...local, syncStatus: 'synced' });
}
}
5. Service Worker — déclenchement de sync au retour online
Pour les patterns avancés de Service Worker et stratégies de cache, lire le guide PWA + Service Worker.
// sw.js — Service Worker minimal
self.addEventListener('online', () => {
self.clients.matchAll().then(clients => {
clients.forEach(client => client.postMessage({ type: 'SYNC_TRIGGER' }));
});
});
// Background Sync API (Chrome/Edge)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-todos') {
event.waitUntil(triggerSync());
}
});
// Côté app
navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('sync-todos');
});
6. Points de vigilance en production
- Ne pas oublier le cleanup de l'outbox : sans nettoyage, elle peut grossir indéfiniment. Job de purge des entrées synced > 7 jours.
- Limiter le retry sur erreurs définitives : un 401/403/422 ne doit JAMAIS être retryé sans intervention (sinon hammering serveur).
- Préserver l'ordre causal : une mise à jour ne doit pas être envoyée avant le create initial → tri par
enqueuedAtdansflush(). - Versionner les données : un champ
version: numbersur chaque entité + checkexpectedVersioncôté serveur évite les pertes silencieuses de modifications. - UX claire : afficher un badge "pending sync" sur les entités
syncStatus !== 'synced'+ un indicateur global "X modifications en attente".
Pour aller plus loin sur les patterns CRDT (résolution automatique sans LWW), explorer Yjs ou Automerge qui fusionnent les modifications concurrentes sans perte. Pour les apps simples, le LWW + version check de ce mini-projet suffit dans 95 % des cas. Pour pousser le typage end-to-end, lire également le guide du strict mode TypeScript qui sécurise tout ce flow de mutations asynchrones.
Conclusion
IndexedDB est une solution puissante pour le stockage local dans les applications web modernes, mais son API native est complexe. Ce mini ORM simplifie drastiquement son utilisation tout en restant léger et sans dépendances. Le pattern offline-first basé sur IndexedDB est devenu incontournable pour toute PWA sérieuse — il transforme l'expérience utilisateur en rendant l'application réactive même sans réseau, et synchronise intelligemment au retour de la connexion.
Pour des projets plus complexes, n'hésitez pas à utiliser Dexie.js qui offre des fonctionnalités avancées (observables, relations, migrations automatiques). Combiné à un Service Worker pour le cache des assets et à une stratégie de résolution de conflits adaptée (Last Write Wins simple, ou CRDT pour le collaboratif), vous obtenez une application web qui rivalise avec les apps natives en termes de réactivité et de robustesse offline.
Investir une journée à comprendre IndexedDB en profondeur est l'un des meilleurs investissements que puisse faire un développeur frontend en 2026. C'est la fondation technique de toutes les PWA modernes, et la connaissance se transpose directement à React Native (SQLite), Electron (better-sqlite3) et même Flutter (Hive, Isar). La pensée « base de données locale » est un changement de paradigme par rapport au « tout serveur » des années 2010 — elle ouvre la porte à des expériences utilisateur drastiquement supérieures, notamment sur mobile où le réseau est lent et intermittent.