IndexedDB : hors ligne et synchronisation
IndexedDB rend possible une application qui fonctionne sans réseau et se synchronise ensuite. Cette page décrit les principes et les pièges.
Architecture type
Section intitulée « Architecture type »Interface ──▶ IndexedDB (source locale) ◀──▶ Synchronisation ◀──▶ API du serveur ▲ service worker (cache des fichiers, resynchronisation)| Élément | Rôle |
|---|---|
| IndexedDB | Source de données locale : l’interface lit et écrit ici |
| Service worker + Cache API | Met en cache les fichiers de l’application (HTML, CSS, JavaScript) pour démarrer hors ligne |
| Synchronisation | Envoie les modifications locales au serveur, récupère celles des autres |
| API serveur | Source de vérité commune (voir Express : API JSON) |
Détecter la connexion
Section intitulée « Détecter la connexion »console.log(navigator.onLine); // true ou false (indication, pas une garantie)window.addEventListener('online', () => synchroniser());window.addEventListener('offline', () => console.log('hors ligne'));navigator.onLine indique seulement qu’une interface réseau est active : une requête peut échouer malgré tout. Traiter l’échec réel des requêtes (fetch rejeté) comme un état hors ligne.
File d’attente d’écritures
Section intitulée « File d’attente d’écritures »Principe : toute modification est d’abord enregistrée localement, avec un marqueur « à envoyer » :
// schéma : magasin « notes » + magasin « envois » (file d'attente)async function enregistrerNote(note) { const db = await ouvrirBase(); const tx = db.transaction(['notes', 'envois'], 'readwrite'); const id = note.id ?? crypto.randomUUID(); tx.objectStore('notes').put({ ...note, id, modifieLe: Date.now() }); tx.objectStore('envois').put({ id, action: 'enregistrer', creeLe: Date.now(), }); await terminee(tx); synchroniser(); // tentative immédiate}
async function synchroniser() { if (!navigator.onLine) return; const db = await ouvrirBase(); const envois = await requete( db.transaction('envois').objectStore('envois').getAll(), );
for (const envoi of envois) { const note = await requete( db.transaction('notes').objectStore('notes').get(envoi.id), ); try { const reponse = await fetch(`/api/notes/${envoi.id}`, { method: envoi.action === 'supprimer' ? 'DELETE' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: envoi.action === 'supprimer' ? undefined : JSON.stringify(note), }); if (!reponse.ok) throw new Error(`HTTP ${reponse.status}`); const tx = db.transaction('envois', 'readwrite'); tx.objectStore('envois').delete(envoi.id); await terminee(tx); } catch { return; // réessayer plus tard } }}Points clés :
- Utiliser des identifiants générés côté client (
crypto.randomUUID()) : pas besoin d’attendre le serveur pour créer un enregistrement hors ligne. - Rendre les requêtes idempotentes (un
PUTavec l’identifiant) : un renvoi après échec ne crée pas de doublon. - Ne pas retenir une transaction pendant un
fetch(voir CRUD et transactions) : lire, puis envoyer, puis ouvrir une nouvelle transaction pour effacer l’élément de la file.
Récupérer les changements du serveur
Section intitulée « Récupérer les changements du serveur »async function recevoir() { const dernier = Number(localStorage.getItem('derniereSynchro') ?? 0); const reponse = await fetch(`/api/notes?depuis=${dernier}`); const { notes, maintenant } = await reponse.json();
const db = await ouvrirBase(); const tx = db.transaction('notes', 'readwrite'); for (const note of notes) tx.objectStore('notes').put(note); await terminee(tx); localStorage.setItem('derniereSynchro', String(maintenant));}Le serveur renvoie les enregistrements modifiés depuis une date (ou un numéro de version) ; le client conserve la date de dernière synchronisation (petite donnée : localStorage convient, voir localStorage).
Conflits
Section intitulée « Conflits »Deux appareils peuvent modifier le même enregistrement hors ligne. Stratégies :
| Stratégie | Principe | Limite |
|---|---|---|
| La dernière écriture gagne | Comparer modifieLe |
Peut perdre une modification |
| Fusion par champ | Fusionner les champs modifiés de chaque côté | Plus de logique |
| Version / ETag | Le serveur refuse (409) si la version a changé ; le client relit et fusionne ou demande à l’utilisateur |
Gestion à écrire |
| Suppression logique | Marquer supprime: true plutôt que supprimer |
Données conservées |
| CRDT (Yjs, Automerge) | Structures de données qui fusionnent automatiquement | Complexité, taille |
Choisir selon la valeur des données : pour des notes personnelles, « la dernière écriture gagne » suffit souvent ; pour des données partagées, versionner et traiter les conflits.
Service worker : démarrer hors ligne
Section intitulée « Service worker : démarrer hors ligne »public/sw.js (cache des fichiers essentiels) :
const CACHE = 'monapp-v1';const FICHIERS = ['/', '/index.html', '/assets/app.js', '/assets/style.css'];
self.addEventListener('install', (e) => { e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FICHIERS)));});
self.addEventListener('activate', (e) => { e.waitUntil( caches .keys() .then((cles) => Promise.all( cles.filter((c) => c !== CACHE).map((c) => caches.delete(c)), ), ), );});
self.addEventListener('fetch', (e) => { if (e.request.method !== 'GET') return; e.respondWith( caches.match(e.request).then((trouve) => trouve ?? fetch(e.request)), );});if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');Le service worker exige HTTPS (ou localhost). Pour une application réelle, utiliser un outil qui génère la liste des fichiers à empreinte (Workbox, plugin PWA de Vite) plutôt que d’écrire la liste à la main : un fichier oublié ou périmé casse le mode hors ligne.
Quotas et éviction
Section intitulée « Quotas et éviction »const { usage, quota } = await navigator.storage.estimate();console.log( `${(usage / 1048576).toFixed(1)} Mo utilisés sur ${(quota / 1048576).toFixed(0)} Mo`,);
const durable = await navigator.storage.persist(); // demande de ne pas être évincéconsole.log('Stockage durable :', durable);| Situation | Conséquence |
|---|---|
| Stockage « best-effort » (par défaut) | Le navigateur peut effacer les données du site en cas de manque d’espace |
Stockage persistant (accordé par persist()) |
Effacement seulement par l’utilisateur |
| Quota dépassé | QuotaExceededError à l’écriture |
| Navigation privée | Données supprimées à la fermeture |
| Navigateurs qui effacent les données de sites peu utilisés | Prévoir une resynchronisation complète |
L’application doit pouvoir reconstruire sa base depuis le serveur. Ne jamais traiter IndexedDB comme la seule copie d’une donnée importante sans avertir l’utilisateur ni proposer un export.
Export et import
Section intitulée « Export et import »async function exporter() { const db = await ouvrirBase(); const donnees = { version: 1, notes: await requete(db.transaction('notes').objectStore('notes').getAll()), }; const fichier = new Blob([JSON.stringify(donnees)], { type: 'application/json', }); const lien = document.createElement('a'); lien.href = URL.createObjectURL(fichier); lien.download = `sauvegarde-${new Date().toISOString().slice(0, 10)}.json`; lien.click(); URL.revokeObjectURL(lien.href);}Proposer une sauvegarde (et sa restauration) permet à l’utilisateur de protéger ses données locales.
Données binaires
Section intitulée « Données binaires »const fichier = input.files[0]; // File ou Blobawait db.put('pieces', { id: crypto.randomUUID(), nom: fichier.name, contenu: fichier,});const piece = await db.get('pieces', id);const url = URL.createObjectURL(piece.contenu); // afficher une image// libérer ensuite : URL.revokeObjectURL(url)IndexedDB stocke directement les Blob : pas besoin de conversion en base64.
Sécurité
Section intitulée « Sécurité »- Pas de secrets dans IndexedDB (lisibles par tout script de la page) : voir Sécurité et confidentialité.
- Authentifier chaque requête de synchronisation côté serveur et valider les données reçues (les données locales peuvent avoir été modifiées).
- Supprimer les données locales à la déconnexion sur un appareil partagé.
Dépannage
Section intitulée « Dépannage »| Symptôme | Piste |
|---|---|
| Les données ont disparu | Éviction par le navigateur, navigation privée, effacement par l’utilisateur : resynchroniser, demander persist() |
| Doublons après resynchronisation | Requêtes non idempotentes ou identifiants générés par le serveur : identifiants côté client + PUT |
| L’application ne démarre pas hors ligne | Service worker non enregistré ou fichiers manquants dans le cache (HTTPS, liste incomplète) |
| Ancienne version de l’application après une mise à jour | Nom de cache non versionné, service worker non remplacé |
| Modifications perdues entre deux appareils | Stratégie de conflit : versions côté serveur |
QuotaExceededError |
Libérer l’espace, supprimer les anciennes données ou pièces jointes |
| La synchronisation ne démarre pas au retour du réseau | online non fiable : tentatives périodiques, ou Background Sync quand disponible |
Ubuntu 24.04 et 26.04 : différences à connaître
Section intitulée « Ubuntu 24.04 et 26.04 : différences à connaître »Aucune. Pour déployer l’application (service worker en HTTPS, en-têtes de cache), voir React : performance et déploiement ou Astro : rendu et déploiement.