Soumission de formulaire hors ligne avec Background Sync
Mettez les soumissions de formulaire en file dans IndexedDB, rejouez-les avec Background Sync et un fallback en ligne, et évitez les doublons avec Idempotency-Key.
Pour qu’une soumission de formulaire survive à une connexion interrompue ou instable, écrivez la soumission dans IndexedDB avant tout appel réseau, enregistrez un tag Background Sync, et laissez le service worker rejouer la requête en file d’attente depuis son événement sync lorsque la connectivité est rétablie — puis ajoutez un écouteur de l’événement online comme solution de repli pour les navigateurs qui ne prennent pas en charge Background Sync. Cette dernière étape n’est pas facultative : l’API Background Sync n’est disponible que dans les navigateurs Chromium. La file d’attente IndexedDB combinée à un écouteur de l’événement online constitue donc la base de référence fonctionnelle sur tous les navigateurs, et Background Sync vient s’y greffer comme amélioration progressive.
Cet article illustre le schéma complet à travers un exemple concret — un formulaire de contact ou de commande — en couvrant chaque aspect : file d’attente durable, enregistrement de la synchronisation, rejeu dans le service worker, solution de repli universelle, prévention des soumissions en double, et le problème de confirmation dont personne ne vous avertit. Il suppose que vous avez déjà enregistré un service worker et que vous êtes à l’aise avec les promesses et fetch. La mise en cache générique (cache-first, network-first) est un prérequis, et non le sujet traité ici — consultez les stratégies de mise en cache de MDN et les modules de stratégie Workbox pour cela.
Points clés à retenir
- Écrivez la soumission dans IndexedDB avec un identifiant de requête généré côté client, un horodatage
queuedAtet un compteur de tentatives avant l’appel réseau, afin qu’un POST interrompu soit déjà persisté plutôt que perdu. - Le Background Sync ponctuel (
SyncManager) n’est pris en charge que par les navigateurs Chromium — Chrome, Edge, Opera et Samsung Internet — et est absent de Firefox, Safari et iOS, ce qui rend un écouteur de l’événementonlinecomme solution de repli indispensable en 2026. - Dans le gestionnaire
syncdu service worker, envoyez chaque élément en file d’attente via POST, supprimez-le en cas de succès, et levez une exception en cas d’échec — lever une exception indique au navigateur de conserver l’enregistrement et de réessayer avec un backoff exponentiel géré par le navigateur. - Envoyez l’identifiant de requête dans un en-tête
Idempotency-Keyafin que le serveur déduplique les soumissions déjà acceptées lorsque le rejeu arrive, évitant ainsi les commandes en double. - Le
BackgroundSyncPluginde Workbox gère la mise en file d’attente et le rejeu à votre place, mais il ne retente que les véritables échecs réseau — une réponse 4xx ou 5xx est considérée comme reçue et ne sera pas rejouée.
Pourquoi un simple fetch à la soumission échoue hors ligne
Un fetch brut lors de la soumission d’un formulaire échoue silencieusement lorsque l’appareil est hors ligne : la promesse est rejetée, la requête n’atteint jamais le serveur, et sans gestion explicite de l’erreur, l’utilisateur ne reçoit aucun signal indiquant qu’un problème est survenu. Les service workers résolvent la mise en cache des ressources statiques, mais ils ne relancent pas automatiquement les nouvelles requêtes de données qu’un formulaire génère — un POST échoué est simplement perdu.
Un POST hors ligne qui échoue silencieusement est le bug classique du type « l’utilisateur croit avoir réussi ; les données ne sont jamais arrivées ». Il est également invisible pour la supervision backend, car la requête n’a jamais atteint le serveur — il n’y a rien à journaliser. La relecture de session est la technique qui comble ce manque de visibilité : une relecture reconstruit la réalité côté client — le tap sur Envoyer, l’écran optimiste « Merci ! », la navigation suivante — que vous pouvez ensuite corréler avec l’apparition ou non d’un enregistrement côté serveur. Cette corrélation est précisément ce qui permet de détecter l’écart de confiance que ce schéma entier cherche à prévenir.
« Soumettre et oublier » doit sembler instantané pour l’utilisateur et durable pour vous : la soumission est capturée localement dès que l’utilisateur appuie sur Envoyer, et la livraison est la responsabilité du système, pas la sienne.
Étape 1 : Mettre la soumission en file d’attente dans IndexedDB avant l’appel réseau
Discover how at OpenReplay.com.
Persistez d’abord la soumission dans IndexedDB, puis tentez la livraison. Stocker avant tout appel réseau signifie qu’un POST échoué, interrompu ou hors ligne est déjà durable — vous rejouez depuis le store, jamais depuis l’état volatile de la page. Attachez trois champs à chaque élément en file d’attente : un identifiant de requête généré côté client (pour la déduplication ultérieure), un horodatage queuedAt, et un retryCount.
// db.js — un wrapper promise minimal autour d'IndexedDB
const DB_NAME = 'outbox';
const STORE = 'submissions';
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function enqueue(payload) {
const db = await openDB();
const item = {
id: crypto.randomUUID(), // identifiant de requête pour l'idempotence
payload,
queuedAt: Date.now(),
retryCount: 0,
};
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(item);
tx.oncomplete = () => resolve(item);
tx.onerror = () => reject(tx.error);
});
}
crypto.randomUUID() est disponible dans tous les navigateurs modernes et dans le contexte du service worker, conformément à la référence MDN de Crypto.randomUUID(). Le gestionnaire de formulaire appelle enqueue, affiche un état optimiste « En attente », puis demande une synchronisation.
Étape 2 : Enregistrer Background Sync, puis gérer l’événement sync
Background Sync vous permet d’enregistrer un tag nommé depuis la page ; le navigateur déclenche un événement sync dans votre service worker lorsqu’il estime que la connectivité est rétablie, et peut déclencher cet événement même après que l’utilisateur a navigué ailleurs ou fermé l’onglet. Ce comportement de livraison différée après navigation est ce qui le rend plus robuste qu’un écouteur online, qui ne se déclenche que lorsque la page est ouverte.
Détectez la présence de serviceWorker et de SyncManager avant de vous y fier, et basculez immédiatement sur la solution de repli si l’un ou l’autre est absent :
// gestionnaire de formulaire, après enqueue()
async function requestSync() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
try {
await reg.sync.register('sync-forms');
return;
} catch {
// l'enregistrement a échoué — on passe à la solution de repli
}
}
flushQueue(); // la solution de repli universelle (Étape 3)
}
L’appel register('sync-forms') et l’interface SyncManager sont documentés dans l’API Background Synchronization sur MDN. Dans le service worker, faites correspondre le tag, encapsulez le traitement dans event.waitUntil() pour que le worker reste actif, envoyez chaque élément en POST, supprimez-le en cas de succès, et levez une exception en cas d’échec pour que le navigateur conserve l’enregistrement et réessaie :
// service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-forms') {
event.waitUntil(replayQueue(event));
}
});
async function replayQueue(event) {
const items = await getAll(); // lecture depuis IndexedDB
for (const item of items) {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id, // déduplication côté serveur
},
body: JSON.stringify(item.payload),
});
if (res.ok) {
await remove(item.id); // suppression en cas de succès
} else if (res.status >= 500) {
if (event.lastChance) await notifyFailure(item);
throw new Error('server error, retry'); // conservation de l'enregistrement
}
// 4xx : la charge utile est invalide — ne pas réessayer aveuglément ; signalez-le plutôt
}
}
Lever une exception à l’intérieur de waitUntil est le signal qui maintient la synchronisation en attente. Les navigateurs qui prennent en charge l’API rejouent les requêtes échouées en votre nom à un intervalle géré par le navigateur, vraisemblablement avec un backoff exponentiel entre les tentatives de rejeu. Le nombre de tentatives et l’intervalle exact sont gérés par le navigateur et ne sont pas documentés contractuellement, donc n’encodez pas en dur une hypothèse du type « trois tentatives ». Vérifiez plutôt event.lastChance — documenté dans la référence MDN de SyncEvent — pour détecter la dernière tentative et informer l’utilisateur que la soumission a finalement échoué, plutôt que de la laisser disparaître silencieusement.
Étape 3 : La solution de repli universelle
Comme Background Sync est réservé à Chromium, l’écouteur de l’événement online n’est pas une note de bas de page — c’est la base de référence que tous les navigateurs exécutent. Deux déclencheurs couvrent le chemin non-Chromium : vider à nouveau la file d’attente à chaque déclenchement de l’événement online, et vider la file à chaque chargement de page pour rattraper les soumissions mises en file lors d’une session précédente.
// s'exécute sur la page, partout
export async function flushQueue() {
if (!navigator.onLine) return;
const items = await getAll();
for (const item of items) {
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id,
},
body: JSON.stringify(item.payload),
});
if (res.ok) await remove(item.id);
} catch {
await bumpRetryCount(item.id); // reste en file pour le prochain déclencheur
}
}
}
window.addEventListener('online', flushQueue);
window.addEventListener('load', flushQueue);
La limitation honnête de cette solution de repli : elle ne s’exécute que lorsqu’une page de votre origine est ouverte, car elle dépend d’événements au niveau de la page. Elle ne peut pas réveiller un onglet fermé comme Background Sync le peut. C’est précisément le compromis — portée universelle, garanties de livraison plus faibles — et c’est pourquoi vous enregistrez Background Sync en premier et basculez sur la solution de repli en second.
Prise en charge de Background Sync par les navigateurs en 2026
En juin 2026, le Background Sync ponctuel (SyncManager) est une API réservée à Chromium. MDN la classe comme ayant une « disponibilité limitée » — pas Baseline, car elle ne fonctionne pas dans certains des navigateurs les plus utilisés. Les données caniuse confirment la répartition ci-dessous.
| Navigateur | Background Sync ponctuel (SyncManager) |
|---|---|
| Chrome | ✅ Pris en charge |
| Edge (Chromium) | ✅ Pris en charge |
| Opera | ✅ Pris en charge |
| Samsung Internet | ✅ Pris en charge |
| Firefox | ❌ Non pris en charge |
| Safari (macOS) | ❌ Non pris en charge |
| Safari (iOS) | ❌ Non pris en charge |
| Android WebView | ❌ Non pris en charge |
Deux points méritent d’être signalés. Microsoft Edge n’a obtenu la prise en charge qu’après son passage à Chromium, donc toute affirmation datant de 2019 selon laquelle « Edge ne le prend pas en charge » est désormais incorrecte. Et Android WebView — le composant que les applications natives embarquent pour la navigation intégrée — n’expose pas SyncManager, de sorte qu’encapsuler une PWA dans un shell WebView fait perdre la file de synchronisation native et bascule sur le chemin de l’événement online. Notez qu’il s’agit de l’API de Background Sync ponctuel ; le Periodic Background Sync est une API distincte et expérimentale pour les mises à jour récurrentes — ne les confondez pas.
Idempotence : éviter les commandes en double lors des rejeux
Tout POST retenté risque de créer un doublon : la première tentative a peut-être atteint le serveur et été validée avant que la connexion ne soit coupée, de sorte que la réponse n’est jamais revenue et que votre client rejoue une requête que le serveur a déjà honorée. Prévenez cela avec un identifiant de requête généré côté client envoyé dans un en-tête Idempotency-Key — le même item.id que vous avez stocké au moment de la mise en file. Le serveur s’appuie dessus et renvoie le résultat original pour une répétition au lieu de créer un second enregistrement.
Idempotency-Key est une convention d’API établie, popularisée par Stripe et PayPal, et fait l’objet d’un Internet-Draft IETF, draft-ietf-httpapi-idempotency-key-header. Traitez-le comme une convention en cours d’élaboration, et non comme un standard ratifié : le champ d’en-tête de requête HTTP Idempotency-Key peut être utilisé pour rendre des méthodes HTTP non idempotentes telles que POST ou PATCH tolérantes aux pannes, mais le draft lui-même porte la mention standard indiquant qu’il ne doit pas être cité autrement qu’en tant que travail en cours. La partie serveur est simple : pour une requête en double dont la clé d’idempotence a déjà été vue, le serveur de ressources doit répondre avec le résultat de l’opération précédemment complétée, qu’il s’agisse d’un succès ou d’une erreur.
UX de confirmation après que l’utilisateur a quitté la page
Le problème UX difficile est que la livraison différée se produit souvent après que l’utilisateur a navigué ailleurs, ce qui nécessite un mécanisme pour réconcilier l’état optimiste « En attente » une fois que la requête a effectivement abouti. Trois approches permettent de couvrir ce cas :
- Basculer « En attente » → « Envoyé » en temps réel. Lorsque le service worker rejoue avec succès un élément, utilisez
postMessagevers tout client ouvert pour que l’interface puisse se mettre à jour en place. Écoutez avecnavigator.serviceWorker.addEventListener('message', ...). - Réconcilier au prochain chargement. Au démarrage, lisez l’état vidé de la file d’attente : les éléments encore présents sont en attente ; leur absence signifie que la livraison a réussi. Affichez le statut depuis le store, pas depuis un indicateur défini au moment de la soumission.
- Signaler un échec terminal. Utilisez
event.lastChancedans le gestionnairesyncpour déclencher une notification (ou persister un marqueur « échoué » que l’interface lit au prochain chargement), afin qu’une soumission ayant épuisé ses tentatives ne disparaisse pas silencieusement.
L’UX de confirmation est le second endroit où la relecture de session est précieuse : la relecture rend visible la chronologie côté utilisateur — la soumission mise en file, l’écran affiché, si la confirmation « Envoyé » a jamais été rendue — ce qui est la seule vue permettant de détecter l’écart de confiance lorsqu’une requête n’a jamais atteint le serveur pour être journalisée.
Workbox comme raccourci pour la production
Si vous préférez ne pas implémenter la file d’attente manuellement, Workbox (actuellement en version majeure 7) fournit BackgroundSyncPlugin, qui met les requêtes échouées en file dans IndexedDB et les rejoue lors des événements sync. Point crucial : il embarque sa propre solution de repli — dans les navigateurs qui ne prennent pas nativement en charge l’API BackgroundSync, Workbox Background Sync tentera automatiquement un rejeu à chaque démarrage de votre service worker.
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSync = new BackgroundSyncPlugin('order-queue', {
maxRetentionTime: 24 * 60, // en minutes ; l'exemple documenté conserve les éléments 24h
});
registerRoute(/\/api\/orders/, new NetworkOnly({ plugins: [bgSync] }), 'POST');
Deux comportements surprennent souvent. Premièrement, BackgroundSyncPlugin s’accroche au callback du plugin fetchDidFail, et fetchDidFail n’est invoqué que si une exception est levée, le plus souvent en raison d’une défaillance réseau, ce qui signifie que les requêtes ne seront pas retentées si une réponse est reçue avec un code d’erreur 4xx ou 5xx. Si vous souhaitez que les réponses 5xx soient remises en file, ajoutez un plugin fetchDidSucceed qui lève une exception lorsque response.status >= 500. Deuxièmement, pour les tests, vous pouvez vérifier que les requêtes ont été mises en file dans Chrome DevTools > Application > IndexedDB, et forcer un rejeu depuis DevTools > Application > Service Workers. Ne validez pas le comportement hors ligne avec la case à cocher « Offline » des DevTools — elle bloque les requêtes de la page mais laisse passer les requêtes du service worker, masquant ainsi exactement les bugs que vous testez. Déconnectez plutôt le réseau réel.
Que vous implémentiez manuellement ou adoptiez Workbox, l’architecture est la même :
- capturer localement avant l’appel réseau ;
- privilégier Background Sync là où il est disponible ;
- se replier sur les événements
onlinepartout ailleurs ; - dédupliquer les rejeux avec une clé d’idempotence ;
- réconcilier l’interface lorsque la livraison aboutit enfin.
Assemblez ces cinq éléments pour votre propre formulaire, puis vérifiez un rejeu réel en mettant en file hors ligne, en vous reconnectant, et en confirmant qu’un seul enregistrement apparaît côté serveur.
FAQ
Quelle est la différence entre le Background Sync ponctuel et le Periodic Background Sync ?
Le Background Sync ponctuel utilise l'interface SyncManager pour rejouer une tâche différée unique, comme une soumission de formulaire en file d'attente, une fois la connectivité rétablie, et le navigateur déclenche un événement sync qui peut arriver même après que l'utilisateur a navigué ailleurs. Le Periodic Background Sync utilise une interface PeriodicSyncManager distincte pour des mises à jour récurrentes selon un calendrier géré par le navigateur, comme l'actualisation de contenu. Ce sont des API distinctes, et le Periodic Background Sync est expérimental — vérifiez son tableau de compatibilité avant de vous en servir en production.
Pourquoi ma requête en file d'attente ne se retente-t-elle toujours pas lorsque le serveur renvoie une erreur 400 ou 500 ?
Background Sync et le BackgroundSyncPlugin de Workbox ne retentent que les requêtes qui échouent en raison d'une véritable erreur réseau, car le plugin s'accroche au callback fetchDidFail, qui ne se déclenche que lorsqu'une exception est levée. Une réponse 400 ou 500 est considérée comme une réponse reçue, donc elle est traitée comme livrée et ne sera pas rejouée. Pour remettre en file les réponses 5xx, ajoutez un plugin fetchDidSucceed qui lève une exception lorsque response.status est égal ou supérieur à 500, ou levez manuellement une exception dans un gestionnaire sync implémenté à la main.
Background Sync fonctionne-t-il dans un Android WebView encapsulant une PWA ?
Non. Android WebView, le composant que les applications natives embarquent pour la navigation intégrée, n'expose pas SyncManager, de sorte qu'une PWA encapsulée dans un shell WebView perd la file de Background Sync native. La soumission bascule sur le chemin de l'événement online, qui ne se déclenche que lorsqu'une page de votre origine est ouverte et ne peut pas réveiller un onglet fermé. Pour cette raison, et en raison de l'absence de prise en charge dans Firefox, Safari et iOS, une file IndexedDB avec un écouteur de l'événement online comme solution de repli doit rester la base de référence.
Comment tester de manière fiable le comportement hors ligne de Background Sync ?
Déconnectez le réseau réel plutôt que d'utiliser la case à cocher 'Offline' des DevTools. La case Offline bloque les requêtes de la page mais laisse passer les requêtes du service worker, masquant ainsi exactement les bugs que vous testez. Vérifiez les éléments en file dans Chrome DevTools sous Application puis IndexedDB, et forcez un rejeu depuis Application puis Service Workers. Pour confirmer le flux complet, mettez une soumission en file hors ligne, reconnectez-vous, et vérifiez qu'un seul enregistrement apparaît côté serveur.