Développement Web

Background Sync API : différer les requêtes jusqu’à la reconnexion

13 دقائق للقراءة

📍 Article principal : Application web installable hors-ligne : architecture complète pas à pas
Ce tutoriel approfondit la synchronisation différée des mutations. Pour la vue d’ensemble de l’architecture, consulter d’abord l’article principal.

Le problème : que faire d’une requête qui échoue parce que le réseau est tombé

Sur une application installable, l’utilisateur ne distingue pas — et ne devrait pas distinguer — un envoi qui part immédiatement d’un envoi qui sera traité plus tard. Quand il clique sur « Enregistrer », la commande doit aboutir, point. Si le réseau est tombé entre la rédaction et le clic, la responsabilité de retenter incombe à l’application, pas à l’utilisateur. C’est exactement la raison d’être de l’API Background Sync : déléguer au navigateur la tâche d’attendre une connexion et de réveiller le service worker pour rejouer les actions en attente.

Sans cette API, deux mauvaises options se présentent. Première option : afficher une erreur sèche « Pas de connexion, réessayez plus tard ». L’utilisateur est puni d’une situation qu’il ne maîtrise pas. Deuxième option : faire semblant que tout s’est bien passé sans rien faire. La modification est perdue et l’utilisateur ne s’en rendra compte que plus tard, dans le meilleur des cas. Background Sync offre la bonne réponse : enregistrer localement, confirmer à l’utilisateur que l’opération est en file, et synchroniser dès le retour du réseau.

État du support navigateur

L’API Background Sync est exposée par l’interface SyncManager, accessible via ServiceWorkerRegistration.sync. Sa disponibilité réelle est partielle : Chrome la supporte depuis la version 49, ainsi que tous les navigateurs basés sur Chromium (Edge, Opera, Samsung Internet, Brave). Firefox et Safari ne l’implémentent pas et n’ont pas annoncé de calendrier. La stratégie correcte est donc l’amélioration progressive : utiliser Background Sync quand disponible, sinon retomber sur un écouteur online côté page.

Ce tutoriel couvre les deux chemins, parce qu’il faut les deux en production. Le code applicatif ne change pas — c’est le mécanisme de réveil qui diffère. Une fois la file d’attente bien conçue, ajouter ou retirer Background Sync ne touche qu’une dizaine de lignes.

Prérequis

  • Un service worker déjà enregistré et actif (voir le tutoriel Service Workers avec Workbox)
  • Une base IndexedDB pour stocker la file (voir le tutoriel Dexie.js)
  • Un endpoint serveur idempotent ou acceptant un identifiant d’idempotence
  • Niveau intermédiaire en JavaScript asynchrone
  • Temps estimé : 45 minutes

Étape 1 — Tester la disponibilité de l’API

La détection se fait par la présence de SyncManager dans l’objet global et de la propriété sync sur la registration du service worker. Ce test doit être fait à chaque tentative d’enregistrement, parce que l’API peut être désactivée par un drapeau utilisateur ou par une politique d’entreprise même sur un navigateur compatible.

function backgroundSyncDisponible() {
  return 'serviceWorker' in navigator
      && 'SyncManager' in window;
}

La détection est synchrone — pas besoin d’attendre la registration. Si la fonction retourne false, on passe directement au mode dégradé via écouteur online. Si elle retourne true, on tente l’enregistrement Background Sync, qui peut toujours échouer pour d’autres raisons (mode privé, restrictions de stockage), auquel cas on retombe sur le mode dégradé.

Étape 2 — Structurer la file d’attente

La file d’attente est un magasin IndexedDB dédié. Chaque entrée représente une mutation à envoyer : type d’action, données utiles, identifiant d’idempotence, date de création, compteur de tentatives. Voici le schéma minimal avec Dexie :

import Dexie from 'dexie';
const db = new Dexie('SyncQueue');
db.version(1).stores({
  pending: '++id, type, dateCreation, idempotenceKey'
});

L’index sur idempotenceKey permet de retrouver rapidement une mutation déjà mise en file pour éviter les doublons en cas de double-clic utilisateur. La clé d’idempotence est typiquement un UUID généré côté client via crypto.randomUUID(). Le serveur la stocke et rejette toute requête entrante portant une clé déjà vue — sans cette protection, un retry réussi mais dont l’accusé de réception a été perdu produirait un doublon en base.

Étape 3 — Enfiler une mutation côté page

La page expose une fonction unique pour les mutations différées. Elle stocke la mutation, demande au navigateur de programmer la synchronisation, et retourne immédiatement à l’appelant. L’UI peut alors afficher une confirmation « Enregistré, en attente de synchronisation ».

async function enfiler(type, payload) {
  const idempotenceKey = crypto.randomUUID();
  await db.pending.add({
    type,
    payload,
    idempotenceKey,
    dateCreation: new Date(),
    tentatives: 0
  });

  if (backgroundSyncDisponible()) {
    try {
      const reg = await navigator.serviceWorker.ready;
      await reg.sync.register('flush-pending');
      return { mode: 'bg-sync', idempotenceKey };
    } catch (err) {
      console.warn('Background Sync register échoué, repli en ligne', err);
    }
  }

  // Repli : si en ligne, tenter immédiatement
  if (navigator.onLine) {
    flushImmediatement().catch(console.error);
  }
  return { mode: 'fallback', idempotenceKey };
}

L’appel reg.sync.register('flush-pending') demande au navigateur de réveiller le service worker dès qu’une connexion est détectée, avec le tag flush-pending. Si le réseau est disponible à ce moment-là, le réveil est quasi-immédiat ; sinon, il intervient dès le retour du réseau. Le tag permet de regrouper plusieurs enregistrements : appeler register deux fois avec le même tag ne crée qu’un seul événement.

Étape 4 — Handler dans le service worker

Côté service worker, l’événement sync est émis avec un objet portant le tag enregistré. Le handler doit appeler event.waitUntil(promise) avec une promesse qui se résout quand toutes les mutations ont été tentées. Si la promesse rejette, le navigateur reprogramme l’événement avec un délai croissant — pas besoin de gérer les retries manuellement.

// Dans sw.js
import Dexie from 'dexie';
const db = new Dexie('SyncQueue');
db.version(1).stores({
  pending: '++id, type, dateCreation, idempotenceKey'
});

self.addEventListener('sync', event => {
  if (event.tag === 'flush-pending') {
    event.waitUntil(flushFile());
  }
});

async function flushFile() {
  const mutations = await db.pending.orderBy('dateCreation').toArray();
  for (const m of mutations) {
    try {
      const resp = await fetch(routeFor(m.type), {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Idempotence-Key': m.idempotenceKey
        },
        body: JSON.stringify(m.payload)
      });
      if (resp.ok || resp.status === 409) {
        // 409 = serveur a déjà cette idempotence-key, donc considéré OK
        await db.pending.delete(m.id);
      } else {
        await db.pending.update(m.id, { tentatives: m.tentatives + 1 });
        throw new Error(`HTTP ${resp.status}`);
      }
    } catch (err) {
      // Rejette pour que le navigateur reprogramme
      throw err;
    }
  }
  // Notifier les onglets ouverts
  const clients = await self.clients.matchAll();
  clients.forEach(c => c.postMessage({ type: 'sync-complete' }));
}

Le statut HTTP 409 est traité comme un succès : il signifie que le serveur connaît déjà cette clé d’idempotence et n’a pas re-traité la mutation. C’est ce qui peut arriver si l’envoi avait abouti mais que l’accusé de réception était perdu. Sans cette nuance, la mutation resterait éternellement en file et serait rejouée à chaque cycle.

Étape 5 — Stratégie de repli sans Background Sync

Pour Firefox et Safari, l’écouteur online de la page sert de déclencheur. Il est complété par un flush au chargement de l’application, parce que online ne s’émet que lorsque la connexion change d’état — pas si l’utilisateur ouvre l’application alors qu’elle était déjà connectée.

async function flushImmediatement() {
  if (!navigator.onLine) return;
  const mutations = await db.pending.orderBy('dateCreation').toArray();
  for (const m of mutations) {
    try {
      const resp = await fetch(routeFor(m.type), {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Idempotence-Key': m.idempotenceKey
        },
        body: JSON.stringify(m.payload)
      });
      if (resp.ok || resp.status === 409) {
        await db.pending.delete(m.id);
      } else if (resp.status >= 500) {
        // Réessayer plus tard
        break;
      } else {
        // 4xx définitif — marquer et abandonner
        await db.pending.update(m.id, {
          tentatives: m.tentatives + 1,
          erreurDefinitive: resp.status
        });
      }
    } catch {
      break; // perte réseau pendant la boucle — on stoppe
    }
  }
}

window.addEventListener('online', flushImmediatement);
window.addEventListener('load', flushImmediatement);

La logique est presque identique à celle du service worker, à deux différences près. D’abord, on s’arrête à la première erreur réseau plutôt que de continuer à tenter les suivantes — le réseau étant probablement indisponible pour tout le batch. Ensuite, les erreurs 4xx sont traitées comme définitives : un 400 (bad request) ou un 403 (forbidden) ne s’arrangera pas en réessayant, il faut marquer la mutation et alerter l’utilisateur.

Étape 6 — Synchroniser l’UI après flush

Que le flush ait eu lieu via le service worker (Background Sync) ou via la page (fallback), les onglets ouverts doivent rafraîchir leur état. La communication se fait par postMessage en venant du service worker, ou par un événement personnalisé en venant de la page.

// Dans la page, écouter les messages du service worker
navigator.serviceWorker.addEventListener('message', event => {
  if (event.data?.type === 'sync-complete') {
    rechargerListePending();
    afficherToast('Synchronisation terminée');
  }
});

// Et écouter le scénario fallback
window.addEventListener('sync-complete', () => {
  rechargerListePending();
});

Le rafraîchissement de la liste consiste à recompter les mutations en attente (db.pending.count()) et à mettre à jour l’indicateur visuel — typiquement un petit pictogramme nuage avec un chiffre. Quand la file est vide, l’indicateur disparaît. Cette boucle de feedback rassure l’utilisateur sur le bon fonctionnement de la synchronisation invisible.

Étape 7 — Gérer les échecs définitifs

Quelques mutations peuvent échouer définitivement : conflit métier non résolvable, autorisations qui ont changé, donnée référencée qui n’existe plus côté serveur. Garder ces mutations en file indéfiniment ne sert à rien. La règle pragmatique : après cinq tentatives infructueuses ou un statut 4xx (sauf 408, 425, 429 qui sont retryables), on déplace la mutation dans un magasin failedMutations et on alerte l’utilisateur.

db.version(2).stores({
  pending: '++id, type, dateCreation, idempotenceKey',
  failed: '++id, type, dateCreation, idempotenceKey, statut, message'
});

async function deplacerEnEchec(mutation, statut, message) {
  await db.transaction('rw', db.pending, db.failed, async () => {
    await db.failed.add({ ...mutation, statut, message, dateEchec: new Date() });
    await db.pending.delete(mutation.id);
  });
  await notifierUtilisateur(mutation, statut, message);
}

La notification utilisateur peut être un toast persistant, un badge sur l’icône de l’application, ou un message dans une zone « Actions à reprendre ». L’utilisateur peut alors décider quoi faire — corriger les données, abandonner, contacter le support — plutôt que de laisser l’application réessayer en boucle silencieusement.

Étape 8 — Conception serveur idempotente

Le client peut faire tout ce qu’il veut côté file et retry : si le serveur ne sait pas reconnaître une mutation déjà reçue, les doublons sont inévitables. La conception serveur idempotente est une responsabilité partagée, et le contrat est simple : chaque mutation porte un identifiant unique stable côté client, le serveur le stocke et rejette toute requête entrante portant un identifiant déjà vu.

L’implémentation typique en PHP, Node.js ou Python tient en quelques lignes. À la réception d’une mutation, le serveur ouvre une transaction, tente d’insérer la clé d’idempotence dans une table dédiée avec une contrainte d’unicité, applique la mutation métier, et commit. Si l’insertion de la clé échoue avec une violation de contrainte, c’est que la mutation a déjà été traitée — le serveur retourne 409 Conflict avec en corps de réponse l’identifiant de la ressource créée précédemment. Le client interprète ce 409 comme un succès et retire la mutation de sa file.

La table des clés peut être purgée après une fenêtre de rétention raisonnable — typiquement 7 jours — pour éviter qu’elle ne grossisse indéfiniment. Au-delà, le risque qu’un client retente une mutation aussi ancienne est nul en pratique. Cette purge se fait par un job nocturne, ou via une politique TTL si la base de données la supporte (Redis, MongoDB, DynamoDB).

Étape 9 — Diagnostiquer en production

Quand un utilisateur signale « j’ai cliqué et ça n’a rien fait », le développeur a besoin d’observabilité côté client et serveur. Côté client, conserver un journal compact en IndexedDB des dernières opérations de synchronisation — début, fin, statut, durée — facilite le diagnostic. Côté serveur, indexer les clés d’idempotence dans les logs structurés permet de retracer toute mutation suspecte de bout en bout. La corrélation se fait via l’en-tête X-Idempotence-Key qui doit apparaître dans les logs nginx, Apache ou autre proxy, en plus des logs applicatifs.

Erreurs fréquentes

Erreur Cause Solution
Aucun événement sync reçu malgré l’enregistrement Navigateur Firefox/Safari, ou Chrome avec restrictions Toujours combiner avec un repli online + flush au chargement
Mutations rejouées plusieurs fois côté serveur Absence de clé d’idempotence ou serveur qui ne la vérifie pas Générer crypto.randomUUID() et la stocker en base serveur, rejeter les doublons en 409
File qui grossit sans se vider Endpoint serveur qui répond 5xx en boucle Vérifier les logs serveur ; ajouter un compteur de tentatives et déplacer en failed après seuil
« Failed to register sync » Service worker non actif, ou registration en mode privé Vérifier navigator.serviceWorker.ready avant ; gérer l’erreur et basculer en repli
Synchronisation déclenchée mais navigateur reste sur la version précédente du service worker Nouveau SW en attente d’activation Forcer le skipWaiting ou attendre la fermeture de tous les onglets

Tutoriels frères

Pour aller plus loin

Questions fréquentes

Combien de temps le navigateur garde-t-il une registration sync en attente ?
Chrome conserve les registrations indéfiniment tant que le service worker n’est pas désinscrit. Si la première tentative échoue, des retries automatiques sont planifiés avec délai exponentiel pendant plusieurs jours. Au-delà, les tentatives s’arrêtent et la registration est abandonnée — le code applicatif doit gérer ce cas en relançant lui-même via online.

Background Sync fonctionne-t-il quand le navigateur est complètement fermé ?
Sur Chrome desktop, oui — le navigateur peut être réveillé en arrière-plan par le système. Sur Chrome mobile Android, oui également. Sur iOS, l’API n’étant pas disponible, la question ne se pose pas. Sur Linux desktop, certaines distributions désactivent le réveil en arrière-plan.

Quelle différence avec Periodic Background Sync ?
Periodic Background Sync permet au navigateur de réveiller le service worker à intervalles réguliers (au minimum quelques heures) même sans changement de connectivité, pour des cas comme « rafraîchir le contenu en arrière-plan ». Cette API est plus restreinte : elle exige que l’application soit installée et utilisée régulièrement. Pour la synchronisation de mutations utilisateur, le Background Sync simple suffit.

Comment tester sans attendre une vraie perte réseau ?
Dans Chrome DevTools, onglet Application, section Service Workers : la case Offline simule la perte, et un champ Sync permet de saisir un tag puis de déclencher manuellement l’événement sync correspondant. Pour analyser l’historique complet des événements, la section Background Services → Background Sync dispose d’un bouton Record qui journalise tous les événements sync pendant 3 jours, même quand DevTools est fermé. Combinés, ces deux outils suffisent pour développer et déboguer sans dépendre d’une vraie coupure.

Que se passe-t-il si l’utilisateur ferme l’onglet avant la synchronisation ?
Avec Background Sync, rien de spécial : le service worker reste enregistré et sera réveillé indépendamment de l’onglet. Sans Background Sync, les mutations restent en file IndexedDB jusqu’à la prochaine ouverture de l’application — l’écouteur load les flushera alors.

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité