Aller au contenu

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.

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)
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.

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 PUT avec 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.
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).

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.

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.

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.

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.

const fichier = input.files[0]; // File ou Blob
await 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.

  • 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é.
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.