ITSkillsCenter
Business Digital

App coursier PWA avec mode offline : tutoriel SvelteKit 2026

12 min de lecture

📍 Article principal : Stack logistique PME 2026

Introduction

Un coursier d’une PME de Pikine entrait quotidiennement dans des bâtiments où la 4G coupait totalement (ascenseurs, sous-sols, intérieurs de marchés couverts). Avant la PWA offline, ces coupures provoquaient des « actions perdues » : le coursier confirmait une livraison, mais l’application affichait une erreur réseau et la confirmation n’était jamais enregistrée. Au siège, on devait constamment appeler les coursiers pour vérifier les livraisons réelles et corriger manuellement. Mise en place d’une PWA SvelteKit avec stockage local IndexedDB et synchronisation différée : toutes les actions du coursier sont enregistrées localement, et synchronisées automatiquement dès que la connexion revient. Plus aucune action perdue, fin des appels de vérification, autonomie totale du coursier en zone basse-couverture. Ce tutoriel décrit l’implémentation complète : service worker, store IndexedDB, queue de synchronisation, géolocalisation continue, signature digitale du destinataire.

Prérequis

  • Projet SvelteKit 2 fonctionnel
  • API backend qui accepte les actions coursier (livraisons, ramassages, positions)
  • HTTPS obligatoire pour les PWA et géolocalisation (un domaine personnalisé suffit)
  • Niveau : intermédiaire avancé — Temps : 4 heures

Étape 1 — Manifest PWA et installation

La PWA installable demande un manifest qui décrit l’application : nom, icônes, couleurs de thème, mode d’affichage. Pour SvelteKit, le manifest se place dans static/manifest.webmanifest et est lié dans le app.html.

{
  "name": "Coursier ITSkills",
  "short_name": "Coursier",
  "description": "Application coursier ITSkills",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0c5d50",
  "theme_color": "#0c5d50",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ],
  "orientation": "portrait"
}

L’utilisateur installe l’application en visitant le site puis tapant « Ajouter à l’écran d’accueil » dans le menu navigateur. Sur Android Chrome, une bannière proposant l’installation apparait automatiquement après quelques visites. Sur iOS Safari, l’utilisateur doit le faire manuellement via le bouton de partage. Pour faciliter, on affiche un bouton « Installer l’application » qui guide l’utilisateur.

Étape 2 — Service worker pour cache et offline

Le service worker intercepte les requêtes réseau et sert les ressources depuis le cache quand la connexion est absente. SvelteKit fournit un système de service worker intégré via le fichier src/service-worker.ts qui s’enregistre automatiquement.

// src/service-worker.ts
import { build, files, version } from '$service-worker';
const CACHE = `coursier-cache-${version}`;
const ASSETS = [...build, ...files];

self.addEventListener('install', (event) => {
  (event as ExtendableEvent).waitUntil(
    caches.open(CACHE).then(cache => cache.addAll(ASSETS))
  );
});

self.addEventListener('activate', (event) => {
  (event as ExtendableEvent).waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.filter(k => k !== CACHE).map(k => caches.delete(k))
    ))
  );
});

self.addEventListener('fetch', (event: any) => {
  const req = event.request;
  if (req.method !== 'GET') return;
  if (req.url.includes('/api/')) return; // API gérée séparément

  event.respondWith(
    caches.match(req).then(cached => cached || fetch(req).then(res => {
      const copie = res.clone();
      caches.open(CACHE).then(cache => cache.put(req, copie));
      return res;
    }))
  );
});

Cette configuration cache toutes les ressources statiques (HTML, JS, CSS, images) lors de l’installation. Quand la 4G coupe, l’utilisateur peut toujours ouvrir l’app et naviguer entre les écrans. Les API sont volontairement exclues du cache pour éviter de servir des données obsolètes — leur gestion offline passe par IndexedDB et la queue de sync (étapes suivantes).

Étape 3 — IndexedDB pour stockage local

IndexedDB stocke les données structurées côté navigateur de manière persistante (les données survivent à la fermeture du navigateur). C’est l’outil idéal pour stocker la liste des courses du coursier, ses confirmations en attente de sync, et les positions GPS récentes. La bibliothèque idb simplifie considérablement l’API native d’IndexedDB.

// src/lib/db.ts
import { openDB, type IDBPDatabase } from 'idb';

interface Schema {
  courses: { key: string; value: any };
  actions_pending: { key: number; value: { type: string; payload: any; timestamp: number }; autoIncrement: true };
  positions: { key: number; value: { lat: number; lng: number; timestamp: number }; autoIncrement: true };
}

let dbPromise: Promise<IDBPDatabase<Schema>> | null = null;

export function getDb() {
  if (!dbPromise) {
    dbPromise = openDB<Schema>('coursier-db', 1, {
      upgrade(db) {
        db.createObjectStore('courses');
        db.createObjectStore('actions_pending', { autoIncrement: true });
        db.createObjectStore('positions', { autoIncrement: true });
      }
    });
  }
  return dbPromise;
}

export async function ajouterAction(type: string, payload: any) {
  const db = await getDb();
  await db.add('actions_pending', { type, payload, timestamp: Date.now() });
}

export async function listerActionsPendantes() {
  const db = await getDb();
  return await db.getAll('actions_pending');
}

Cette structure simple stocke trois types de données : la liste des courses synchronisées du serveur, les actions effectuées par le coursier en attente de remontée serveur, et l’historique récent des positions GPS pour le tracking. La persistence garantit que même si l’utilisateur ferme l’app ou que le téléphone redémarre, les données ne sont pas perdues.

Étape 4 — Queue de synchronisation

Le pattern central de la PWA offline est la queue de synchronisation. Chaque action coursier (confirmation de ramassage, livraison, position) est d’abord enregistrée localement dans IndexedDB, puis remise au serveur quand la connexion le permet. Si le serveur retourne une erreur réseau, l’action reste en queue et sera retentée plus tard. Si elle réussit, on la supprime de la queue locale.

// src/lib/sync.ts
import { getDb, listerActionsPendantes } from './db';

export async function synchroniser() {
  const actions = await listerActionsPendantes();
  for (const a of actions) {
    try {
      const r = await fetch(`/api/actions/${a.value.type}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(a.value.payload)
      });
      if (r.ok) {
        const db = await getDb();
        await db.delete('actions_pending', a.key);
      } else {
        // Erreur métier : garder l'action en queue pour révision manuelle
        console.warn('Action rejetée:', a, r.status);
        break;
      }
    } catch (e) {
      // Erreur réseau : on retente plus tard
      console.warn('Erreur réseau, retry plus tard');
      break;
    }
  }
}

// Synchroniser dès que la connexion revient
window.addEventListener('online', synchroniser);
// Synchroniser périodiquement aussi
setInterval(synchroniser, 30000);

L’événement online du navigateur se déclenche dès que la connexion revient — synchronisation immédiate. Le setInterval toutes les 30 secondes est une protection complémentaire au cas où l’événement online ne se déclencherait pas correctement (cas observé sur certains Android d’entrée de gamme). Cette double protection garantit que la sync arrive dès que possible.

Étape 5 — Géolocalisation continue

Pour le tracking de la position du coursier, on utilise navigator.geolocation.watchPosition qui écoute en continu les mises à jour GPS. Pour économiser la batterie, on configure enableHighAccuracy: false et un intervalle de 30 secondes minimum entre les mesures. La position est stockée localement dans IndexedDB et poussée vers le serveur via la queue de sync.

let watchId: number | null = null;

export function demarrerTracking() {
  if (!navigator.geolocation) return;
  watchId = navigator.geolocation.watchPosition(
    (pos) => {
      const position = { lat: pos.coords.latitude, lng: pos.coords.longitude, timestamp: Date.now() };
      stockerPosition(position);
      ajouterAction('position', position);
    },
    (err) => console.warn('Geo error:', err),
    { enableHighAccuracy: false, maximumAge: 30000, timeout: 10000 }
  );
}

export function arreterTracking() {
  if (watchId !== null) navigator.geolocation.clearWatch(watchId);
  watchId = null;
}

Pour le tracking en arrière-plan (quand l’app est minimisée), les PWA ont des limitations : sur Android Chrome, la géolocalisation continue uniquement si l’app est au premier plan, sauf utilisation de l’API Background Sync. Pour les besoins critiques de tracking en arrière-plan, considérer une migration vers Capacitor ou Flutter pour une vraie app native. Pour la majorité des cas (coursier qui garde son téléphone allumé au premier plan pendant sa tournée), la PWA suffit.

Étape 6 — Signature digitale du destinataire

Pour la preuve de livraison, on capture la signature du destinataire via un canvas tactile. La bibliothèque signature_pad fait cela en quelques lignes. La signature est convertie en image PNG, stockée temporairement dans IndexedDB, puis uploadée vers Hetzner Object Storage avec la confirmation de livraison.

import SignaturePad from 'signature_pad';

let canvas: HTMLCanvasElement;
let pad: SignaturePad;

onMount(() => {
  pad = new SignaturePad(canvas, { backgroundColor: 'rgb(255,255,255)', penColor: 'rgb(0,0,0)' });
});

async function confirmerLivraison() {
  if (pad.isEmpty()) { alert('Signature requise'); return; }
  const signaturePng = pad.toDataURL('image/png');
  await ajouterAction('livraison', {
    courseId: course.id,
    signaturePng,
    timestamp: Date.now()
  });
}

Pour la légalité de la signature digitale en Afrique de l’Ouest, le cadre juridique évolue mais reste favorable dans la plupart des pays UEMOA. La signature accompagnée d’un horodatage, d’une géolocalisation, et d’une photo du destinataire avec son colis constitue une preuve solide en cas de litige. Documenter cette pratique dans les CGU de la plateforme renforce sa valeur juridique.

Erreurs fréquentes

Erreur Cause Solution
Service worker pas enregistré HTTP au lieu de HTTPS HTTPS obligatoire (Let’s Encrypt gratuit)
Cache jamais mis à jour Pas de stratégie d’invalidation Inclure version dans nom de cache
Actions perdues malgré IndexedDB Erreur silencieuse dans openDB Logger les erreurs et alerter
Géolocalisation refusée Permission utilisateur non demandée Premier appel géoloc déclenche le prompt système
App ne s’installe pas Manifest invalide ou icônes manquantes Vérifier via Lighthouse PWA audit
Sync ne se déclenche jamais offline → online Event « online » non fiable sur certains Android Backup avec setInterval périodique

Adaptation au contexte ouest-africain

Trois considérations spécifiques. Premièrement, sur les Tecno et Itel d’entrée de gamme avec 2 Go de RAM, IndexedDB peut atteindre des limites de quota plus rapidement que sur des smartphones haut de gamme. Limiter le stockage local à l’essentiel : courses du jour, dernière semaine de positions, signatures en attente d’upload. Au-delà, archiver côté serveur et libérer le local. Deuxièmement, pour les coursiers qui rechargent peu (forfaits prépayés), optimiser la consommation de données : ne pas synchroniser les images de signature en haute résolution, compresser avant upload, dépriorisation sync auto en mode « préserver mes données ». Troisièmement, l’expérience utilisateur doit être ultra-simple : interface tactile minimaliste avec gros boutons, peu de saisie clavier, validation par tap plutôt que par formulaire complexe. Les tests utilisateur sur de vrais coursiers, idéalement sur leur propre téléphone, révèlent rapidement les frictions à corriger.

Pour le déploiement, une PWA sur Android Chrome se met à jour automatiquement à chaque ouverture. Pas besoin de passer par le Play Store, pas de validation Google, pas de version qui traîne. Cette agilité de mise à jour est un avantage majeur face aux apps natives traditionnelles. Pour iOS, Safari 16+ supporte les PWA mais avec quelques limitations (pas de notifications push fiables jusqu’à iOS 16.4). Pour la majorité des utilisateurs ouest-africains qui sont majoritairement sur Android, ce point n’est pas bloquant.

Tutoriels frères

Pour aller plus loin

FAQ

Quelle limite IndexedDB en pratique ?
Sur Android Chrome, environ 6 % de l’espace disque libre par défaut, montant à plusieurs dizaines de Go sur les téléphones bien équipés. Pour une app coursier, on tient bien sous cette limite.

Comment forcer une mise à jour de la PWA ?
Le service worker détecte les nouveaux assets au prochain démarrage et active la nouvelle version. On peut afficher un bandeau « Nouvelle version disponible » qui propose à l’utilisateur de recharger immédiatement.

Les notifications push fonctionnent-elles ?
Sur Android Chrome oui via Push API + Web Push. Sur iOS Safari depuis 16.4 oui mais avec des limitations. Pour les notifications critiques, prévoir un fallback SMS ou WhatsApp.

Comment passer à une app native ultérieurement ?
Capacitor wrappe une PWA dans un container Android/iOS natif sans réécriture. Migration progressive possible : démarrer en PWA pure, basculer en Capacitor quand des fonctionnalités natives spécifiques deviennent nécessaires.

Migration vers Capacitor pour fonctions avancées

Quand la PWA atteint ses limites (notifications push fiables iOS, géolocalisation en arrière-plan plus de 15 minutes, scan de code-barres caméra avec performance native), Capacitor offre un chemin de migration progressif sans réécriture. La même base de code SvelteKit/PWA est wrappée dans un container Android et iOS natif via une commande Capacitor. Les API natives manquantes deviennent accessibles via des plugins Capacitor (background-geolocation, push-notifications, camera, filesystem). Pour une PME logistique qui démarre en PWA pure, cette option de migration future rassure : pas de prison technologique, transition possible sans repartir de zéro.

Le passage en Capacitor demande typiquement deux à trois semaines de travail pour une équipe expérimentée : configuration des plugins natifs, gestion du build Android et iOS, soumission Play Store et App Store, support des notifications push via Firebase Cloud Messaging. Ce n’est pas trivial mais reste largement plus rapide qu’une réécriture complète en Flutter ou React Native. Pour les agences ouest-africaines qui veulent proposer ce service à leurs clients PME, une expertise Capacitor capitalisée sur quelques projets devient un actif technique différenciant.

Sécurité spécifique PWA

La PWA s’exécute dans le contexte navigateur avec les protections standards (sandbox CORS, CSP, HTTPS), mais quelques points d’attention spécifiques. Premièrement, le stockage IndexedDB n’est pas chiffré par défaut — pour des données sensibles (numéros de téléphone clients, informations livraisons confidentielles), envisager un chiffrement applicatif via Web Crypto API avec une clé dérivée du PIN de l’utilisateur. Deuxièmement, le service worker peut intercepter toutes les requêtes — un service worker compromis pourrait exfiltrer des données. Mitigation : déploiement uniquement via HTTPS depuis un domaine de confiance, signature des assets servis. Troisièmement, l’authentification du coursier doit être robuste : les tokens JWT stockés dans IndexedDB sont accessibles via JavaScript, donc vulnérables au XSS. Mitigation : strict CSP qui interdit l’inline JavaScript, refresh tokens courts en HttpOnly cookies, monitoring des connexions inhabituelles.

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité