Introduction : Construire pour Madagascar
Éditée par l'entreprise Tarondro, Dago est une plateforme de VTC et de livraison en pleine croissance, conçue spécifiquement pour les réalités de Madagascar. Les enjeux y sont majeurs. Les connexions réseau peuvent être instables, les téléphones ont des capacités très variables, et l'économie unitaire dicte la viabilité de l'entreprise. Dans un marché où les marges sont fines, dépenser les tarifs d'un cloud auto-scalant classique pour chaque course n'est tout simplement pas soutenable.
En tant que CTO de Tarondro, mon rôle a été de concevoir un système backend capable de gérer des milliers d'utilisateurs simultanés en temps réel, sans faire exploser les coûts d'infrastructure. Le défi était clair. Il fallait une fiabilité de niveau entreprise, mais avec un budget de startup optimisé.
Le problème à 0,003 $
Chaque plateforme de transport urbain tient ou tombe sur une seule expérience : le client qui regarde un point se déplacer sur une carte.
Ce point n'est pas un effet visuel. Derrière se trouve un pipeline de données temps réel. Ce sont des signaux GPS collectés toutes les quelques secondes depuis le téléphone d'un rider, relayés par internet, traités par un serveur, et poussés sur l'écran du client assez vite pour paraître instantanés.
En faisant les comptes, les résultats étaient sans appel. À 500 courses par jour, l'infrastructure totale revient en moyenne à 0,003 $ par course (calculé sur 15 000 courses mensuelles). Moins d'un centime. Sur un seul serveur à 40 $/mois.
Voici l'architecture qui rend ça possible. Voici surtout les décisions en coulisses.
Pourquoi un VPS mono-serveur à 40 $ plutôt que Firebase + Google Maps ?
Beaucoup de startups sont naturellement attirées par les services managés : en quelques heures, on branche Firebase, Google Maps, et la démo fonctionne.
Le problème, c'est que cette vitesse initiale raconte rarement toute l'histoire. Dès que l'usage réel commence (plus d'utilisateurs, plus d'événements temps réel, plus de contraintes locales), la simplicité de la démo peut se transformer en latence imprévisible et en facture qui grimpe.
Notre décision n'était donc pas idéologique. Elle était économique et opérationnelle.
Notre contrainte principale n'était pas de gagner 2 semaines de dev. C'était de garantir un coût unitaire stable pendant la phase d'adoption.
En une vue :
| Critère | VPS mono-serveur (notre choix) | Firebase + Google Maps |
|---|---|---|
| Coût | Fixe au départ (~40-60 $/mois) | Variable selon trafic et appels API |
| Prévisibilité | Forte (budget plafonné) | Moyenne à faible en croissance |
| Performance locale | Optimisable de bout en bout | Dépend de plusieurs services externes |
| Données | Contrôle complet (infra + logs) | Réparties chez plusieurs fournisseurs |
| Démarrage | Plus d'ops au début | Plus rapide pour prototyper |
| Lock-in | Faible | Plus élevé |
Le vrai arbitrage, c'était simplement :
- Aller plus vite en démo, avec une facture qui peut vite devenir imprévisible.
- Investir un peu plus en backend, pour garder un coût par course stable.
Pour Dago, en marché émergent avec marges serrées, l'option 2 était plus rationnelle.
En clair : on n'a pas rejeté Firebase ou Google Maps. On les a simplement remis à plus tard, quand leur confort produit apportera plus de valeur que leur coût marginal.
Ce choix nous donne aussi un chemin de montée en charge propre : tant que le coût par course reste sous contrôle, on garde l'architecture sobre ; quand un palier business est atteint, on peut réintroduire des services managés de façon ciblée (traffic ETA, analytics, push), sans réécrire le cœur temps réel.
L'architecture derrière le point qui bouge
L'approche naïve échoue à l'échelle
Le vrai problème, ce n'est pas seulement de "recevoir des coordonnées GPS". C'est de préserver deux choses en même temps : la confiance du client (un point fluide, sans lag) et la marge de l'entreprise (un coût par course soutenable).
L'approche que la plupart des équipes essaient en premier paraît logique : chaque seconde, l'app du rider envoie ses coordonnées au serveur par HTTP, le serveur écrit en base, puis l'app client relit la base chaque seconde.
Ça marche en démo. En production, ça casse vite.
À 100 riders simultanés, c'est déjà 100 écritures/seconde et 100 lectures/seconde, uniquement pour la localisation. À 500 riders, la base croule, le serveur s'étrangle sous les handshakes HTTP, la latence monte, et la facture cloud grimpe plus vite que le chiffre d'affaires.
Autrement dit : l'approche naïve dégrade l'expérience utilisateur et vos marges en même temps. L'approche réfléchie échange un peu de conception contre 85 % de réduction des coûts et de meilleures performances.
La solution : le pipeline à 5 ms
L'architecture repose sur une règle simple : chaque outil fait ce qu'il sait faire de mieux.
💡 En clair : Le polling HTTP, c'est appeler toutes les 3 secondes pour demander « t'es où ? ». WebSocket, c'est garder la ligne ouverte : la position arrive dès qu'elle change.
Quatre briques, quatre rôles distincts :
- WebSocket maintient une connexion ouverte en permanence entre le rider et le serveur. Pas de handshake HTTP à chaque update : la porte est déjà ouverte, les données circulent librement.
- Redis garde chaque position en mémoire vive avec un index géospatial natif. Concrètement, chaque
GEOADDmet à jour la position d'un rider en sub-milliseconde, et chaqueGEOSEARCHretrouve les riders les plus proches d'un point en une seule commande. Pas de requête SQL, pas de lecture disque. Redis diffuse aussi chaque mouvement instantanément au client qui suit la course, via les rooms Socket.io. - PostgreSQL reçoit les positions en batch toutes les 30 secondes. Il ne voit jamais le débit temps réel. Son rôle : archiver les trajets pour les litiges, l'analytics, et la facturation.
- OSRM, auto-hébergé sur le même VPS, calcule les itinéraires et les ETAs à partir du réseau routier malgache complet. C'est l'équivalent de Google Maps Directions, mais gratuit et local. Les données restent à Madagascar.
Le résultat : une mise à jour de position traverse le pipeline entier en moins de 5 ms. À 500 riders simultanés, le système absorbe plus de 10 000 opérations par minute, là où une architecture HTTP + PostGIS serait déjà en difficulté à 100.
// Le handler WebSocket complet - un update, quatre résultats
@SubscribeMessage('location:update')
async handleLocationUpdate(client: Socket, dto: LocationUpdateDto) {
const riderId = client.data.userId;
// 1. Ignorer si ce rider a envoyé un update il y a moins de 2s (trick Redis TTL)
if (!(await this.redisGeo.checkRateLimit(riderId))) return;
// 2. Mettre à jour la position dans l'index géo Redis (sous 1ms)
await this.redisGeo.updatePosition(riderId, dto.lat, dto.lng);
// 3. Pousser à tous les clients qui regardent cette course (instantané)
this.server.to(`ride:${dto.rideId}`).emit('location:update', dto);
// 4. Bufferiser pour l'écriture batch en base (PAS d'insert direct)
this.batchService.buffer(riderId, dto);
}Deep Dive Technique
La réduction de 85 % : le tracking deux vitesses
C'est l'insight unique qui a le plus transformé notre modèle de coûts.
Tous les riders n'ont pas besoin d'être trackés à la même fréquence. Un rider à l'arrêt qui attend une course n'a pas besoin d'envoyer du GPS toutes les 3 secondes. Personne ne le regarde. Sa position pour le matching n'a besoin que d'être approximative.
On a conçu deux vitesses de tracking :
| État du rider | Fréquence GPS | Coût bande passante | Utilité |
|---|---|---|---|
| Idle (en ligne, en attente) | Toutes les 60 secondes | ~0,25 KB/min | Position approximative pour le matching |
| En mission (course active) | Toutes les 3–10 secondes | ~7 KB/min | Tracking temps réel pour le client |
| Hors ligne | Rien | 0 $ | Pas de tracking |
Avec 500 riders en ligne et 50 en mission, on passe de 10 000 messages/min à 1 450 messages/min. Même expérience utilisateur. 85 % de messages en moins.
Quand un rider accepte une course, son app bascule en mode rapide. Quand la course se termine, elle revient au heartbeat lent. Le client ne voit jamais la mécanique.
// Flutter - l'app rider adapte sa fréquence GPS à l'état de la mission
Duration get _interval => switch (_state) {
RiderState.offline => Duration.zero, // pas de tracking
RiderState.idle => Duration(seconds: 60), // heartbeat léger
RiderState.onMission => _getSpeedBasedInterval(), // 3-10s adaptatif
};Et on est allé plus loin. Même pendant une mission, la fréquence s'adapte à la vitesse du rider :
| Vitesse | Intervalle | Pourquoi |
|---|---|---|
| Arrêté (trafic, feu rouge) | 10 secondes | La position ne change pas |
| Trafic lent (sous 5 m/s) | 4 secondes | Précision modérée suffisante |
| En mouvement rapide (plus de 5 m/s) | 3 secondes | Précision maximale pour l’animation fluide |
Et l'app n'envoie un update que si le rider a bougé d'au moins 10 mètres. Cela empêche le jitter GPS de noyer le serveur de données inutiles.
Trouver le rider le plus proche en moins d'une milliseconde
Quand un client demande une course, on doit trouver le rider disponible le plus proche. Pas en 2 secondes. Pas en 500 millisecondes. En moins d'une milliseconde, parce que la vitesse de dispatch est un avantage compétitif.
Les positions du heartbeat 60 secondes sont déjà dans Redis avec un index géospatial. Une seule commande répond à « donne-moi les 10 riders les plus proches dans un rayon de 5 km » :
// Une commande Redis trouve les 10 riders les plus proches - instantané
const nearby = await redis.call(
'GEOSEARCH', 'active_riders',
'FROMLONLAT', customerLng, customerLat,
'BYRADIUS', 5, 'km', 'ASC', 'COUNT', 10
);On vérifie ensuite lesquels sont libres et on envoie l'offre au plus proche. S'il refuse, le suivant reçoit l'offre. Le matching entier se fait en mémoire, sans requête base de données.
Pourquoi pas un moteur de matching plus sophistiqué ?
On a étudié le système DISCO d'Uber. C'est l'utilisation de cellules Google S2, de hachage consistant, et du protocole gossip Ringpop pour distribuer des millions de connexions. De l'ingénierie impressionnante pour des millions de chauffeurs.
À l'échelle de Dago (des centaines de riders, pas des millions), Redis GEORADIUS donne le même résultat avec une seule commande au lieu d'un système distribué :
| Notre approche | Approche Uber | Quand on basculerait |
|---|---|---|
| Redis GEORADIUS | Grille hexagonale S2/H3 | 50K+ riders simultanés (besoin de sharding) |
| Instance Redis unique | Hachage consistant multi-nœuds | Plusieurs serveurs Redis nécessaires |
| Offre séquentielle (plus proche d'abord) | Optimisation par lot (pondération ETA) | Quand les courses perdues par refus deviennent mesurables |
| OSRM pour distance routière | Google Maps Directions API | Quand l'ETA traffic-aware devient un différenciateur |
Le heartbeat 60 secondes donne des positions vieilles de 60 secondes au maximum. Comme un rider met 2 à 5 minutes pour rejoindre le client, l'erreur ajoutée est négligeable. Pas besoin d'un système plus complexe pour l'instant.
Animations fluides sans facture Google Maps
Le client voit un rider qui glisse doucement sur la carte. En réalité, les mises à jour GPS arrivent toutes les 3–10 secondes. Elles n'arrivent pas 60 fois par seconde.
L'astuce : l'interpolation côté client. Quand une nouvelle position arrive, l'app n'y téléporte pas le marqueur. Elle l'anime à 60 images/seconde entre les deux points :
// Flutter - animation entre les pings GPS à 60fps
Timer.periodic(Duration(milliseconds: 16), (timer) {
final t = (elapsed / duration).clamp(0.0, 1.0);
final lat = start.latitude + (end.latitude - start.latitude) * t;
final lng = start.longitude + (end.longitude - start.longitude) * t;
_updateMarker(LatLng(lat, lng));
});Pour l'infrastructure cartographique, on a évité toute dépendance fournisseur :
| Composant | Fournisseur | Coût |
|---|---|---|
| Tuiles de carte | Mapbox tier gratuit | 0 $ (jusqu'à 50K chargements web ou 25K MAUs mobile) |
| Routage & ETA | OSRM auto-hébergé | 0 $ (réseau routier de Madagascar, tourne sur notre VPS) |
| Géocodage | Nominatim | 0 $ (open-source) |
💡 En clair : OSRM, c'est un moteur de calcul d'itinéraires. C'est comme Google Maps Directions, mais qu'on fait tourner nous-mêmes sur notre propre serveur avec l'intégralité du réseau routier malgache. Ça ne coûte rien, et les données restent à Madagascar.
Le seul coût qui pourrait évoluer, c'est Mapbox au-delà de leur généreux tier gratuit (ex. 50 000 chargements web ou 25 000 Utilisateurs Actifs Mensuels sur mobile). Même alors, le coût marginal reste des fractions de centime par course, ce qui est dérisoire par rapport aux revenus qui justifieraient ce volume.
Un serveur qui se protège lui-même
Tourner sur un seul VPS avec 8 Go de RAM signifie qu'on ne peut pas auto-scaler lors des pics. À la place, on a conçu le serveur pour dégrader gracieusement plutôt que crasher.
Budget mémoire : chaque octet compte
| Composant | RAM |
|---|---|
| OS + Docker overhead | ~800 Mo |
| PostgreSQL | ~1,5 Go |
| Redis (géo + cache) | ~512 Mo |
| NestJS App | ~512 Mo |
| OSRM (routes Madagascar) | ~1,5 Go |
| Nginx | ~64 Mo |
Sur 8 Go de RAM : ~4,9 Go utilisés, ~2 Go de marge pour les connexions WebSocket, ~1,1 Go de buffer de sécurité.
Avec 2 Go de marge et ~20-50 Ko par connexion WebSocket, on tient confortablement 5 000+ connexions simultanées avant d'atteindre une limite.
Quand la mémoire dépasse un seuil, le serveur bascule automatiquement en fréquence plus conservative. Il gagne du temps sans couper de connexions. Quand les connexions approchent du maximum, les nouvelles sont refusées proprement :
// Le serveur se surveille toutes les 10 secondes
@Cron('*/10 * * * * *')
async checkHealth() {
const heapUsedMB = process.memoryUsage().heapUsed / 1024 / 1024;
// Sous pression ? Demander aux riders d'envoyer moins souvent
this.degradedMode = heapUsedMB > 400;
}C'est le compromis VPS : 40 $/mois fixe au lieu de 200–2 000 $/mois en cloud auto-scalant. Les contraintes forcent de meilleures décisions d'ingénierie.
Livraison fiable pour les messages qui ne peuvent pas être perdus
Les mises à jour de position peuvent être perdues. La suivante remplace la précédente. Mais certains messages ne doivent pas être perdus : une offre de course, une annulation, une confirmation de paiement.
Pour ceux-là, on a implémenté un pattern de livraison au-moins-une-fois inspiré du système RAMEN d'Uber. Le serveur envoie, attend l'accusé de réception, et retente si rien n'arrive dans les 5 secondes :
// Le serveur stocke le message en attente, retente si pas d'ACK
await this.redis.setex(`pending:${riderId}:${seq}`, 30, payload);
server.to(`rider:${riderId}`).emit('ride:offer', message);
// Après 5s sans ACK → retry. Après 2 retries → rider suivant.| Type de message | Livraison fiable ? | Pourquoi |
|---|---|---|
ride:offer | ✅ Oui | Offre perdue = chiffre d'affaires perdu |
ride:cancelled | ✅ Oui | Le rider doit le savoir immédiatement |
location:update | ❌ Non | Le prochain update la remplace |
eta:update | ❌ Non | Les ETA périmées se remplacent vite |
C'est crucial à Madagascar où les réseaux Telma et Orange sont parfois instables. Un rider qui rate une offre à cause d'une micro-coupure de 3 secondes, c'est du revenu perdu. La couche de retry ne coûte presque rien et l'empêche.
Le business case en un tableau
| Stade | Courses/jour | Riders simultanés | Coût mensuel | Coût par course |
|---|---|---|---|---|
| MVP | 100 | 20 | 40–60 $ | 0,003 $ |
| Croissance | 1 000 | 200 | 80–120 $ | 0,005 $ |
| Scale | 5 000 | 1 000 | 150–200 $ | 0,004 $ |
Le coût par course reste sous un centime pendant les premiers milliers d'utilisateurs actifs quotidiens. Comparez avec une approche cloud-first naïve : 500–2 000 $/mois au stade Croissance pour les mêmes charges.
La différence n'est pas la complexité technique. C'est l'intentionnalité de conception.
Chemin de scaling : pas de réécriture, juste de la configuration
L'architecture a un chemin de montée en charge clair à chaque point d'inflexion :
Phase 1 : VPS unique. Tout sur un serveur (40-60 $/mois)
│ Signal : 5K+ connexions WebSocket soutenues
▼
Phase 2 : Services séparés. PostgreSQL + OSRM sur 2ᵉ VPS (80-120 $/mois)
│ Signal : 85%+ d'utilisation RAM soutenue
▼
Phase 3 : Multi-serveur. Ajout adaptateur Redis (1 ligne de code) (150-200 $/mois)
│ Signal : 50K+ connexions simultanées
▼
Phase 4 : Migration cloud. Kubernetes, infra managée
La transition Phase 1→2 est un changement de configuration Docker. Phase 2→3 est littéralement un import :
// Une ligne pour passer de mono-serveur à multi-serveurs
import { createAdapter } from '@socket.io/redis-adapter';
io.adapter(createAdapter(pubClient, subClient));Pas de réécriture. Pas de migration. Pas de changement d'architecture.
Ce qu'on n'a pas construit (et ce qui a raté)
Un document d'architecture honnête inclut les lacunes.
Le tracking « à la demande » : une fausse bonne idée
Notre première architecture était un piège élégant. L'idée de départ semblait brillante : ne tracker aucun rider en permanence. Quand un client demande une course, le serveur envoie une notification silencieuse à tous les riders proches. Leur téléphone se réveille, récupère sa position GPS, et la renvoie au serveur. Le serveur compare les réponses et attribue la course au plus proche. Zéro connexion WebSocket. Zéro coût quand personne ne commande. Sur le papier, c'est du génie.
En pratique, ça s'effondre pour trois raisons que seule la production révèle :
-
La latence des notifications push est imprévisible. Une notification silencieuse via FCM (Firebase Cloud Messaging) met entre 500 ms et 15 secondes à arriver, selon le réseau, l'OS, et si le téléphone est en mode Doze. Le client qui attend un rider voit un spinner pendant 5 à 15 secondes avant même que le matching commence. Sur les réseaux Telma et Orange à Antananarivo, c'était souvent plus proche des 10 secondes. Inacceptable.
-
L'OS tue l'app en arrière-plan. Android et iOS tuent agressivement les apps en arrière-plan pour économiser la batterie. Une notification silencieuse suppose que l'app est encore vivante pour la recevoir et exécuter du code. Sur les téléphones d'entrée de gamme (la majorité de nos riders), l'app était tuée en quelques minutes. Le rider semblait « en ligne » côté serveur, mais son téléphone ne recevait plus rien.
-
Le GPS cold start ajoute 5 à 30 secondes. Quand le téléphone n'a pas utilisé le GPS récemment, le premier fix prend du temps. Notification (5s) + GPS cold start (10s) + réponse serveur (1s) = le client attend potentiellement 15 secondes juste pour le matching, sans compter le temps de trajet du rider.
Le tracking continu par WebSocket avec le heartbeat 60 secondes coûte un peu de bande passante, mais il élimine les trois problèmes d'un coup. Le rider est déjà connecté, sa position est déjà dans Redis, et le matching se fait en sub-milliseconde. La leçon : une architecture « zéro coût au repos » ne vaut rien si elle détruit l'expérience au moment critique.
Les compromis assumés (pour l'instant)
On a sauté le surge pricing. Uber utilise des grilles hexagonales H3 pour découper les villes en cellules de demande et ajuster les prix dynamiquement. On y a pensé, mais le volume ne le justifie pas encore. Quand ce sera le cas, le package h3-js permet de mapper les coordonnées en cellules hexagonales côté Node.js, qu'on peut ensuite stocker dans des clés Redis standards. C'est une migration propre depuis notre GEOSEARCH actuel.
On a sauté l'ETA traffic-aware. Notre routage OSRM donne des ETAs précises en distance routière, mais ne tient pas compte du trafic en temps réel. Pour les embouteillages d'Antananarivo, nos ETAs peuvent être décalées de 2–5 minutes aux heures de pointe. L'API Google Maps Traffic corrigerait ça. Cela coûte 7 $ pour 1 000 requêtes. On l'ajoutera quand le volume de courses le justifie.
On a choisi JSON plutôt que Protobuf. C'est correct pour l'instant. À notre échelle, les payloads JSON coûtent environ 5x plus de bande passante que la sérialisation binaire. À 500 riders, c'est la différence entre 110 Go et 22 Go par mois. Les deux sont largement dans les limites du VPS. À 10 000 riders, Protobuf deviendra une optimisation sérieuse.
On a sous-estimé le jitter GPS. Les premiers tests à Antananarivo ont montré une précision GPS qui rebondissait de 20–50 mètres en zone urbaine dense. Le seuil de mouvement de 10 mètres attrape la plupart du jitter, mais les riders à l'arrêt « dérivent » parfois sur la carte du client. Un filtre de Kalman lisserait ça. On ne l'a pas encore implémenté.
Pas de stack de monitoring. On a délibérément sauté Grafana/Prometheus pour économiser ~500 Mo de RAM sur le VPS. On se repose sur docker stats et les logs applicatifs. Ça marche, mais on a eu deux incidents où on n'a découvert la pression mémoire qu'après des signalements utilisateurs. Une solution de monitoring légère est la prochaine priorité.
Ce qu'on a appris
La bonne architecture ne consiste pas à copier l'infrastructure d'Uber ou à empiler les services Cloud à la mode. Elle consiste à faire correspondre la technologie aux contraintes de votre business.
Pour Dago, cela signifiait accepter que les réseaux mobiles de Madagascar ne sont pas parfaitement stables, et qu'un coût unitaire d'un demi-centime était la condition de survie du projet. Le tracking « deux vitesses », le routage auto-hébergé et le pipeline WebSocket + Redis ne sont que la traduction technique de ces contraintes.
La leçon transportable pour n'importe quel produit : chaque système a une optimisation « deux vitesses » cachée sous ses yeux. Qu'il s'agisse de capteurs IoT, de pipelines d'analytics ou de systèmes de notifications, la plupart des entreprises paient cher pour traiter à haute fréquence des données qui n'ont pas toutes la même valeur.
Identifiez ce qui ne nécessite pas une résolution à la seconde. Ralentissez-le. Sortez votre base de données de la boucle temps réel. Et regardez vos coûts chuter de 85 %, sans jamais dégrader l'expérience de vos utilisateurs.
Si vous construisez une plateforme avec des contraintes similaires (fonctionnalités temps réel, infrastructure économe, marchés émergents), parlons de votre architecture. La première session de 30 minutes est gratuite, sans engagement.

