Benchmark des librairies de cartes web : Leaflet, pigeon-maps, OpenLayers, MapLibre en raster et en vectoriel
Benchmark reproductible de Leaflet 1.9 et 2.0, pigeon-maps, OpenLayers, maplibre-gl-leaflet, MapLibre GL JS et react-map-gl sur une carte de station de ski : poids au premier chargement et avec cache, poids des tuiles PNG, WebP et vectorielles au chargement et au zoom, temps de chargement, données brutes et procédure de reproduction.
Conversation ayant produit cet article
En bref. Quel est le coût réel, en octets et en millisecondes, d’une carte web selon la librairie et le type de tuiles ? Sur un MacBook M2 en profil mobile simulé (4G, CPU ralenti ×4), pigeon-maps est la plus légère au premier chargement (431 ko avec des tuiles PNG, contre 468 ko pour Leaflet 1.9 et 786 ko pour MapLibre GL JS). Mais dès que l’utilisateur zoome, le vectoriel prend l’avantage : zoomer de deux niveaux ne coûte aucun octet en vectoriel, contre 469 ko de tuiles PNG avec Leaflet. Sur une session « chargement + deux zooms », MapLibre en vectoriel (788 ko) et OpenLayers en vectoriel (755 ko) passent sous Leaflet en PNG (937 ko). Sur un écran desktop, l’écart grandit : 903 ko contre 2 404 ko. Le choix dépend surtout de la quantité de navigation attendue et du budget JavaScript accepté au premier affichage. Données brutes et procédure de reproduction en section 9 et en annexe.
1. Résultats en un coup d’œil
Profil mobile (390×844 px, densité de pixels ×3, 4G simulée à 9 Mb/s et 170 ms par requête, processeur ralenti ×4). Médianes de 10 mesures ; ko = 1 000 octets réellement transférés sur le réseau. Meilleure valeur de chaque colonne en gras.
| Solution | Tuiles | 1er chargement (ko) | dont JS (ko) | Tuiles du zoom 14 → 16 (ko) | Session totale (ko) | Carte prête, cache vide (ms) | Carte prête, avec cache (ms) |
|---|---|---|---|---|---|---|---|
| Leaflet 1.9 | PNG | 467,7 | 97,5 | 469,2 | 936,9 | 1 066 | 334 |
| Leaflet 1.9 | WebP | 384,3 | 97,5 | 408,0 | 792,2 | 996 | 344 |
| Leaflet 2.0 α | PNG | 462,3 | 94,0 | 469,2 | 931,5 | 1 040 | 320 |
| pigeon-maps | PNG | 431,1 | 66,6 | 625,0 | 1 056,1 | 1 098 | 312 |
| pigeon-maps | WebP | 347,7 | 66,6 | 551,9 | 899,5 | 1 038 | 312 |
| OpenLayers | PNG | 485,6 | 119,9 | 469,2 | 954,7 | 1 401 | 647 |
| OpenLayers | vectoriel | 755,3 | 195,2 | 0,0 | 755,3 | 2 108 | 1 164 |
| maplibre-gl-leaflet | vectoriel | 902,7 | 452,2 | 0,0 | 902,7 | 2 043 | 841 |
| MapLibre GL JS | PNG | 786,2 | 413,2 | 491,5 | 1 277,8 | 1 716 | 758 |
| MapLibre GL JS | vectoriel | 788,4 | 413,2 | 0,0 | 788,4 | 1 895 | 808 |
| react-map-gl | vectoriel | 792,8 | 417,6 | 0,0 | 792,8 | 1 915 | 823 |
| Baseline : page React vide | — | 59,4 | 59,1 | — | — | 475 | 263 |
« Session totale » = premier chargement + octets transférés pendant les deux zooms. Le tableau complet (18 combinaisons × 2 profils, p95, tas JavaScript, fluidité du zoom) est en section 5.
Verdict par cas d’usage :
- Carte affichée, peu manipulée (encart de localisation, page de contact) : pigeon-maps ou Leaflet en WebP. Le JavaScript ajouté au cadre React va de 7 ko (pigeon-maps) à 38 ko (Leaflet 1.9), et la carte est prête en 1 s environ sur mobile simulé.
- Carte que l’on explore (plan des pistes, recherche d’un point) : le vectoriel. MapLibre GL JS paie environ 354 ko de JavaScript de plus que la baseline, mais ce coût est amorti dès le premier zoom. Sur une session courte, il passe sous les solutions raster PNG, et l’écart grandit avec chaque zoom et sur grand écran.
- Raster obligatoire : passer le fond de carte en WebP. Il pèse moitié moins que le PNG (116 ko contre 248 ko au chargement mobile), alors que le même encodage alourdit l’overlay de pistes, qui pèse 41 % de plus en WebP qu’en PNG.
- OpenLayers en vectoriel est le plus léger sur la session (755 ko), mais il est aussi le plus lent à s’afficher sur mobile (2 108 ms), le plus gourmand en mémoire (17,7 Mo) et le moins fluide au zoom (22 images/s, contre 60 à 65 pour les autres).
2. Le problème
Une carte de station de ski combine un fond cartographique, une couche « pistes » et de la navigation : on arrive sur une vue d’ensemble, puis on zoome sur un secteur. Le choix technique se fait souvent sur un seul critère, la taille du bundle (Leaflet est « léger », MapLibre est « lourd »), alors que le poids réellement transféré dépend autant des tuiles que du code.
Ce benchmark ne mène à aucune décision immédiate. Il cherche à chiffrer ces compromis sur un cas concret : la zone de Val d’Isère/Tignes, au point 14/45.56911/6.83055, avec trois familles de tuiles :
- raster PNG : tuiles OpenStreetMap (OSM) standard + overlay de pistes OpenSnowMap ;
- raster WebP : les mêmes tuiles transcodées en WebP ;
- vectoriel : le style
winterdetiles.ublo.app, un style MapLibre de 77 couches, déjà évoqué à propos de l’auto-hébergement d’OpenFreeMap, des sprites et des glyphes.
Ce benchmark ne mesure pas le rendu de grandes quantités d’objets (marqueurs, GeoJSON), ni la qualité visuelle, ni l’ergonomie de développement.
3. Les solutions comparées
| Solution | Version | Tuiles testées | Remarques |
|---|---|---|---|
| Baseline : page React vide | React 19.3.0, Vite 8.3.4 | — | isole le coût du cadre commun |
| Leaflet 1.x | 1.9.4 | PNG, WebP | stable actuelle |
| Leaflet 2.0 | 2.0.0-alpha.1 | PNG, WebP | réécriture ESM, toujours en alpha à date [Source : Leaflet 2.0 alpha, téléchargements] |
| pigeon-maps | 0.22.1 | PNG, WebP | composant React sans dépendance |
| Leaflet + maplibre-gl-leaflet | 1.9.4 + 0.1.4 + maplibre-gl 6.14.0 | PNG, WebP, vectoriel | carte Leaflet dont le rendu est délégué à MapLibre |
| OpenLayers | ol 10.11.0 (+ ol-mapbox-style 13.5.1 en vectoriel) | PNG, WebP, vectoriel | |
| MapLibre GL JS | 6.14.0 | PNG, WebP, vectoriel | rendu WebGL |
| react-map-gl | 8.1.3 + maplibre-gl 6.14.0 | PNG, WebP, vectoriel | composant React autour de MapLibre |
Leaflet 1.9, Leaflet 2.0 et pigeon-maps ne savent pas afficher de tuiles vectorielles sans plugin. Ces trois combinaisons figurent dans les données comme unsupported.
Écartées d’office :
- Mapbox GL JS : licence propriétaire et clé obligatoire.
- Google Maps et Apple Maps : tuiles propriétaires.
- react-leaflet : enveloppe de Leaflet dont le coût est très proche de notre composant minimal.
- Plugins vectoriels pour Leaflet (protomaps-leaflet, VectorGrid) : ils ne lisent pas un style MapLibre, donc le rendu ne serait pas comparable.
Configuration : les options par défaut de chaque librairie, sauf deux adaptations indispensables :
- pigeon-maps n’a pas de couche de tuiles superposée. L’overlay de pistes y est dessiné par un petit composant maison (
TileOverlay, ~40 lignes, publié dans le harnais) qui suit la même règle de choix de niveau que pigeon-maps. - MapLibre 6 exige, avec un bundler, de déclarer explicitement l’URL de son worker (
setWorkerUrl). Sans cet appel, aucune tuile ne se charge. J’ai suivi la procédure Vite de la documentation officielle [Source : Guide de migration v5 → v6].
4. Méthodologie
4.1 Pages de test et scénario
Chaque combinaison librairie × tuiles est une page React construite par Vite en mode production. Toutes les pages partagent le même cadre ; seul le composant carte change.
Chaque mesure (« run ») suit trois étapes dans un Chromium neuf :
- Chargement à froid (cache HTTP vide) jusqu’à « carte prête », puis 1 s d’attente.
- Zoom : deux zooms animés successifs (+1 puis +1), avec l’animation par défaut de chaque librairie. Pour OpenLayers, c’est 250 ms, la durée de son contrôle Zoom, car
view.animateseul vaut 1 s. On attend « carte prête » après chaque palier. - Seconde visite dans un nouvel onglet du même contexte, cache HTTP chaud, jusqu’à « carte prête ».
Convention de zoom. Le point de départ 14/45.56911/6.83055 est exprimé dans la convention MapLibre, qui compte les niveaux de zoom sur une grille de tuiles de 512 px. Leaflet, pigeon-maps et OpenLayers comptent sur une grille de 256 px : pour la même échelle, leur zoom vaut un de plus. Les pages MapLibre et react-map-gl vont donc de 14 à 16, les autres de 15 à 17, et toutes affichent la même zone. Garder le même nombre partout aurait fait afficher à MapLibre une zone deux fois plus petite de côté. Je l’ai constaté pendant la mise au point : avec zoom: 14 partout, MapLibre demandait des tuiles raster jusqu’au niveau 17, Leaflet jusqu’au 16.
4.2 Rejeu réseau
Mesurer contre les vrais serveurs de tuiles aurait été à la fois bruité et contraire aux règles d’usage d’OSM. Toutes les mesures passent donc par un serveur de rejeu local :
┌──────────────┐ --host-resolver-rules ┌──────────────────────────────┐
│ Chromium │ ───────────────────────▶ │ Serveur HTTP/2 + TLS local │
│ (Playwright) │ (toutes les URL │ - pages de test (brotli) │
└──────────────┘ d'origine conservées) │ - réponses enregistrées │
│ - WebP précalculés │
│ - comptage des octets │
│ - latence + débit simulés │
└──────────────────────────────┘
- URL d’origine conservées. Chromium est lancé avec
--host-resolver-rules, qui envoie tous les noms d’hôte vers le serveur local. Le certificat auto-signé est accepté par son empreinte SPKI (Subject Public Key Info) via--ignore-certificate-errors-spki-list, la méthode de WebPageReplay. Avec un simple--ignore-certificate-errors, Chrome ne met rien en cache, ce qui aurait faussé la mesure « avec cache ». - En-têtes de cache rejoués.
Cache-Control,ETaget les autres en-têtes sont rejoués tels qu’enregistrés. Côté tuiles OSM, c’estmax-age=50784; côté ublo, jusqu’à 10 ans pour les tuiles. - Comptage des octets côté serveur. On compte le corps HTTP envoyé, encodé, en-têtes exclus. Ce qui sort du cache du navigateur n’atteint jamais le serveur, donc on compte exactement les octets transférés. Ce choix capture aussi les requêtes des Web Workers, où MapLibre charge ses tuiles vectorielles.
- Contenu enregistré une seule fois. Pour le raster, une passe hors ligne a d’abord listé les tuiles réellement demandées par les pages. Un script a ensuite téléchargé ces 148 tuiles OSM et 148 tuiles OpenSnowMap, une fois, en séquence, avec 250 ms entre les requêtes et un User-Agent identifié. La tile usage policy d’OSM interdit pourtant les robots qui zooment pour forcer le chargement : c’est une entorse assumée, à cette échelle et une seule fois. Les réponses vectorielles de
tiles.ublo.appet les webfonts chargées par ol-mapbox-style ont été enregistrées pendant deux passes du navigateur. - WebP précalculé. Chaque PNG est transcodé avec sharp 0.35.5, en WebP avec perte, qualité 80, transparence conservée. Les en-têtes de cache d’origine sont gardés : seul le format change. Aucun serveur OSM public ne sert de WebP.
- Rejeu complet. Pendant toute la campagne, aucune réponse n’a manqué au cache : la métrique
missingvaut 0 partout.
4.3 Métriques
| Métrique | Définition | Unité | Sens | Mesure |
|---|---|---|---|---|
| 1er chargement | Octets transférés, cache vide, jusqu’à « carte prête » + 1 s | ko | ↓ | serveur de rejeu |
| Avec cache | Idem, seconde visite | ko | ↓ | serveur de rejeu |
| JS | Part JavaScript du 1er chargement ; surcoût = différence avec la baseline | ko | ↓ | serveur de rejeu |
| Tuiles initiales / zoom | Octets de tuiles (et requêtes) au chargement, puis entre « prête » au premier niveau et « prête » au troisième. Les ressources de style (style, TileJSON, sprites, glyphes, webfonts) sont comptées à part | ko, nombre | ↓ | serveur de rejeu, catégorie par URL |
| Session totale | 1er chargement + octets du zoom | ko | ↓ | dérivée |
| Carte prête | Navigation → premier état « toutes les tuiles visibles chargées » : load de chaque TileLayer (Leaflet), idle (MapLibre), rendercomplete (OpenLayers), chargement de toutes les <img> (pigeon-maps) | ms | ↓ | performance.now() dans la page |
| FCP / LCP | First Contentful Paint / Largest Contentful Paint | ms | ↓ | PerformanceObserver |
| TBT | Total Blocking Time : somme des (durée − 50 ms) des tâches longues jusqu’à « carte prête » | ms | ↓ | PerformanceObserver (longtask) |
| Tas JS | Mémoire JavaScript utilisée à « carte prête », thread principal | Mo | ↓ | Chrome DevTools Protocol (CDP) Performance.getMetrics |
| FPS au zoom | Images par seconde (requestAnimationFrame) pendant les animations de zoom | img/s | ↑ | compteur dans la page |
Il n’y a pas de score agrégé : un tableau multicritère et un verdict par cas d’usage. Un écart est jugé significatif à partir de 20 %.
4.4 Environnement
Extrait de environment.json :
- MacBook Apple M2 (4 cœurs performance + 4 efficacité, 24 Go), macOS 15.8.1, sur secteur, mode économie d’énergie désactivé.
- Node 24.21.0, Playwright 1.64.0, Chromium 156.0.8078.4 en mode headless.
- Rendu WebGL matériel :
ANGLE Metal Renderer: Apple M2. Par défaut, Chromium headless sur macOS rend le WebGL en SwiftShader, un rendu logiciel sur processeur, ce qui aurait pénalisé MapLibre. Le harnais passe--use-angle=metal --enable-gpuet s’arrête s’il détecte SwiftShader. - Profil mobile : 390×844 px,
deviceScaleFactor3, écran tactile, processeur ralenti ×4 via CDP, réseau simulé par le serveur (170 ms ajoutées avant chaque réponse, 9 Mb/s partagés entre toutes les requêtes). - Profil desktop : 1440×900 px,
deviceScaleFactor2, aucun ralentissement.
4.5 Protocole
- 19 combinaisons (18 + baseline) × 2 profils × (2 warm-up écartés + 10 mesures) = 456 runs, en 45 minutes.
- Ordre entrelacé pseudo-aléatoire, graine 20261012 : chaque solution subit les mêmes dérives de la machine.
- Un Chromium neuf par run ; timeout de 60 s par étape.
- Le cadrage a été validé avant la campagne et figé dans
protocol.md, avec le journal des décisions prises en cours de route.
5. Résultats détaillés
Toutes les valeurs sont des médianes. Le tableau complet par profil, avec les p95 et les coefficients de variation (CV), est dans summary.md.
5.1 Poids sur une session : chargement puis zoom
| Solution (mobile) | Tuiles | 1er chargement (ko) | Tuiles initiales (ko) | Tuiles zoom (ko) | Session totale (ko) |
|---|---|---|---|---|---|
| Leaflet 1.9 | PNG | 467,7 | 364,3 (24 req.) | 469,2 (44 req.) | 936,9 |
| Leaflet 1.9 | WebP | 384,3 | 280,8 (24 req.) | 408,0 (44 req.) | 792,2 |
| Leaflet 2.0 α | PNG | 462,3 | 364,3 (24 req.) | 469,2 (44 req.) | 931,5 |
| Leaflet 2.0 α | WebP | 378,9 | 280,8 (24 req.) | 408,0 (44 req.) | 786,8 |
| pigeon-maps | PNG | 431,1 | 364,3 (24 req.) | 625,0 (60 req.) | 1 056,1 |
| pigeon-maps | WebP | 347,7 | 280,8 (24 req.) | 551,9 (60 req.) | 899,5 |
| maplibre-gl-leaflet | PNG | 889,9 | 423,2 (30 req.) | 508,2 (50 req.) | 1 398,1 |
| maplibre-gl-leaflet | WebP | 801,4 | 334,7 (30 req.) | 437,4 (50 req.) | 1 238,7 |
| maplibre-gl-leaflet | vectoriel | 902,7 | 207,0 (20 req.) + 229,0 de style | 0,0 | 902,7 |
| OpenLayers | PNG | 485,6 | 364,3 (24 req.) | 469,2 (44 req.) | 954,7 |
| OpenLayers | WebP | 402,2 | 280,8 (24 req.) | 408,0 (44 req.) | 810,1 |
| OpenLayers | vectoriel | 755,3 | 137,4 (13 req.) + 421,2 de style | 0,0 | 755,3 |
| MapLibre GL JS | PNG | 786,2 | 364,3 (24 req.) | 491,5 (46 req.) | 1 277,8 |
| MapLibre GL JS | WebP | 702,8 | 280,8 (24 req.) | 421,7 (46 req.) | 1 124,6 |
| MapLibre GL JS | vectoriel | 788,4 | 137,4 (13 req.) + 229,0 de style | 0,0 | 788,4 |
| react-map-gl | PNG | 790,6 | 364,3 (24 req.) | 491,5 (46 req.) | 1 282,2 |
| react-map-gl | WebP | 707,2 | 280,8 (24 req.) | 421,7 (46 req.) | 1 129,0 |
| react-map-gl | vectoriel | 792,8 | 137,4 (13 req.) + 229,0 de style | 0,0 | 792,8 |
Sur desktop, l’écran plus grand multiplie les tuiles raster, et le vectoriel creuse l’écart :
| Solution (desktop) | Tuiles | 1er chargement (ko) | Tuiles zoom (ko) | Session totale (ko) |
|---|---|---|---|---|
| Leaflet 1.9 | PNG | 946,7 | 1 457,1 (130 req.) | 2 403,8 |
| Leaflet 1.9 | WebP | 712,6 | 973,8 (130 req.) | 1 686,3 |
| pigeon-maps | PNG | 910,1 | 2 359,4 (216 req.) | 3 269,5 |
| OpenLayers | PNG | 964,5 | 1 457,1 (130 req.) | 2 421,7 |
| OpenLayers | vectoriel | 869,9 | 0,0 | 869,9 |
| maplibre-gl-leaflet | vectoriel | 1 118,9 | 0,0 | 1 118,9 |
| MapLibre GL JS | PNG | 1 265,2 | 2 077,3 (174 req.) | 3 342,5 |
| MapLibre GL JS | vectoriel | 903,0 | 0,0 | 903,0 |
| react-map-gl | vectoriel | 907,4 | 0,0 | 907,4 |
Ce qu’il faut lire :
- Le zoom vectoriel est gratuit, ici. Les sources vectorielles du style (planet, contours, winter-sports) s’arrêtent au niveau 14, et le relief raster au 12. Au-delà, MapLibre et OpenLayers agrandissent (« surzooment ») les tuiles déjà chargées au lieu d’en demander de nouvelles. Et comme la vue après zoom est contenue dans la vue initiale, aucun octet n’est transféré. Ce résultat dépend du style : une source qui descendrait jusqu’au niveau 16 coûterait des tuiles au zoom.
- Le raster coûte à chaque niveau. Avec les tuiles PNG, deux zooms coûtent 469,2 ko sur mobile avec Leaflet, soit plus que le premier chargement complet (467,7 ko).
- pigeon-maps et MapLibre téléchargent plus pendant le zoom raster. pigeon-maps passe au niveau suivant à mi-animation, quand la vue couvre encore une zone plus large que la vue finale, ce qui fait 60 requêtes contre 44 pour Leaflet sur mobile. MapLibre demande lui aussi davantage de tuiles pendant le zoom sur desktop (174 requêtes contre 130), sans que j’aie isolé le mécanisme.
- maplibre-gl-leaflet charge plus de tuiles que Leaflet seul (30 contre 24 au chargement mobile). Le plugin dessine un canevas MapLibre plus grand que la vue (
paddingpar défaut). - Leaflet 1.9 et 2.0 transfèrent les mêmes tuiles. Leaflet 2.0 gagne 3,5 ko de JavaScript.
5.2 Ventilation par couche
| Couche (mobile, chargement initial) | Leaflet PNG | Leaflet WebP | MapLibre vectoriel | OpenLayers vectoriel |
|---|---|---|---|---|
| Fond OSM | 247,8 | 116,2 | — | — |
| Overlay OpenSnowMap | 116,5 | 164,6 | — | — |
| Tuiles vectorielles planet | — | — | 58,1 | 58,1 |
| Courbes de niveau | — | — | 18,1 | 18,1 |
| Domaines skiables (winter-sports) | — | — | 22,2 | 22,2 |
| Relief raster (hillshade, WebP) | — | — | 39,0 | 39,0 |
| Style + TileJSON | — | — | 15,7 | 15,7 |
| Sprites | — | — | 78,2 | 78,2 |
| Glyphes (polices PBF) | — | — | 135,1 | — |
| Webfonts (jsDelivr) | — | — | — | 327,3 |
Valeurs en ko. Style (12,5) et TileJSON (3,2) sont regroupés dans ce tableau ; ils sont séparés dans summary.md.
- WebP : un gain sur le fond, une perte sur l’overlay. Le WebP avec perte en qualité 80 divise le fond OSM par 2,1 (247,8 → 116,2 ko). En revanche, il alourdit l’overlay OpenSnowMap de 41 % (116,5 → 164,6 ko). Ces tuiles sont surtout transparentes, avec quelques traits de couleur : un PNG à palette les compresse très bien, alors que le WebP avec perte encode un canal alpha complet. Pour une couche de ce type, il faudrait tester le WebP sans perte, ou garder le PNG.
- En vectoriel, les tuiles ne sont pas le poste principal. Les ressources de style (229,0 ko pour MapLibre) pèsent plus que les tuiles elles-mêmes (137,4 ko). Glyphes et sprites dominent ; ils sont mis en cache pour longtemps et partagés par toutes les pages du site.
- OpenLayers charge des webfonts au lieu des glyphes. ol-mapbox-style dessine les libellés avec les polices du navigateur. Par défaut, il va chercher les fontes manquantes sur le CDN Fontsource de jsDelivr [Source : configuration d’ol-mapbox-style] : ici, 327,3 ko de Noto Sans. L’option
webfontsou la métadonnéeol:webfontspermettent de pointer vers des fontes plus légères ou auto-hébergées.
5.3 JavaScript
| Solution | Rendu | JS transféré (ko) | Surcoût vs page React vide (ko) |
|---|---|---|---|
| pigeon-maps | raster | 66,6 | 7,4 |
| Leaflet 2.0 α | raster | 94,0 | 34,9 |
| Leaflet 1.9 | raster | 97,5 | 38,3 |
| OpenLayers | raster | 119,9 | 60,7 |
| OpenLayers + ol-mapbox-style | vectoriel | 195,2 | 136,1 |
| MapLibre GL JS | raster et vectoriel | 413,2 | 354,0 |
| react-map-gl | raster et vectoriel | 417,6 | 358,4 |
| maplibre-gl-leaflet | raster et vectoriel | 452,2 | 393,1 |
Octets compressés en brotli (qualité 11), comme les servirait un CDN qui précompresse. Le chiffre de MapLibre inclut son worker, chargé dans un fichier séparé. react-map-gl n’ajoute que 4,4 ko à MapLibre. maplibre-gl-leaflet cumule Leaflet et MapLibre.
5.4 Temps de chargement
| Solution (mobile) | Tuiles | Carte prête (ms, p95) | Avec cache (ms, p95) | FCP (ms) | LCP (ms) | TBT (ms) | Tas JS (Mo) |
|---|---|---|---|---|---|---|---|
| Baseline | — | 475 (487) | 263 (275) | — | — | 0 | 2,3 |
| Leaflet 1.9 | PNG | 1 066 (1 081) | 334 (358) | 584 | 1 004 | 6 ⚠ | 3,4 |
| Leaflet 1.9 | WebP | 996 (1 006) | 344 (355) | 590 | 854 | 9 ⚠ | 3,4 |
| Leaflet 2.0 α | PNG | 1 040 (1 057) | 320 (334) | 562 | 972 | 0 | 3,2 |
| pigeon-maps | PNG | 1 098 (1 132) | 312 (325) | 524 | 846 | 0 | 3,1 |
| pigeon-maps | WebP | 1 038 (1 062) | 312 (324) | 528 | 796 | 0 | 3,1 |
| OpenLayers | PNG | 1 401 (1 419) | 647 (660) | 618 | 618 | 5 ⚠ | 4,1 |
| OpenLayers | vectoriel | 2 108 (2 147) | 1 164 (1 240) | 692 | 692 | 210 | 17,7 ⚠ |
| maplibre-gl-leaflet | vectoriel | 2 043 (2 067) | 841 (859) | 864 | 864 | 120 | 9,5 |
| MapLibre GL JS | PNG | 1 716 (1 728) | 758 (767) | 800 | 800 | 1 ⚠ | 6,4 ⚠ |
| MapLibre GL JS | WebP | 1 619 (1 637) | 733 (784) | 788 | 788 | 0 ⚠ | 8,2 ⚠ |
| MapLibre GL JS | vectoriel | 1 895 (1 913) | 808 (823) | 778 | 778 | 55 ⚠ | 13,6 ⚠ |
| react-map-gl | vectoriel | 1 915 (1 932) | 823 (851) | 792 | 792 | 55 ⚠ | 8,6 |
⚠ = coefficient de variation supérieur à 10 %. Sur des TBT de quelques millisecondes, un CV élevé n’a pas de portée pratique. Sur le tas JavaScript, il traduit le passage plus ou moins tardif du ramasse-miettes.
En desktop, sans ralentissement, Leaflet 1.9 et 2.0 et pigeon-maps sont prêts en 57 à 82 ms. OpenLayers, MapLibre et react-map-gl le sont en 375 à 460 ms (356 à 424 ms avec cache). Une part de cet écart tient à la définition de « carte prête », qui n’est pas la même partout (voir section 7).
Ce qu’il faut retenir :
- Sur mobile, le JavaScript coûte cher même en cache. Avec un cache chaud, aucun octet n’est transféré. Pourtant, MapLibre met 733 à 808 ms à afficher la carte, contre 312 à 344 ms pour Leaflet et pigeon-maps : il faut évaluer plus de 1,5 Mo de JavaScript décompressé sur un processeur ralenti ×4.
- Le premier affichage (FCP) arrive plus tôt avec les librairies légères : 524 ms pour pigeon-maps, 584 ms pour Leaflet 1.9, 778 à 800 ms pour MapLibre.
- Le vectoriel bloque davantage le thread principal : TBT de 55 ms pour MapLibre, 120 ms pour maplibre-gl-leaflet, 210 ms pour OpenLayers. Leaflet, pigeon-maps et OpenLayers en raster restent sous 10 ms ; maplibre-gl-leaflet en raster monte à 42-46 ms.
- Mémoire : 3,1 à 3,4 Mo pour pigeon-maps et Leaflet, 13,6 Mo pour MapLibre en vectoriel, 17,7 Mo pour OpenLayers en vectoriel. Ce sont des mesures du thread principal seul : les workers de MapLibre n’y sont pas comptés.
5.5 Zoom : durée et fluidité
| Solution (mobile) | Tuiles | Deux zooms jusqu’à « prête » (ms) | FPS pendant l’animation |
|---|---|---|---|
| Leaflet 1.9 | PNG | 784 | 60 |
| pigeon-maps | PNG | 1 276 | 65 |
| OpenLayers | PNG | 1 667 | 62 |
| OpenLayers | vectoriel | 1 274 | 22 |
| MapLibre GL JS | PNG | 2 193 | 61 |
| MapLibre GL JS | vectoriel | 1 980 | 61 |
Toutes les librairies tiennent 60 images par seconde pendant l’animation, sauf OpenLayers en vectoriel : 22 img/s sur mobile et 55 sur desktop. Il redessine les tuiles vectorielles sur un canevas 2D à chaque image, là où MapLibre utilise le processeur graphique. Les durées de zoom reflètent surtout la durée d’animation propre à chaque librairie et l’attente de « carte prête » ; elles ne mesurent pas une réactivité perçue.
5.6 Échecs et anomalies
-
Aucun échec ni timeout sur les 456 runs ; aucune réponse manquante au rejeu.
-
Trois combinaisons ne sont pas supportées : vectoriel × Leaflet 1.9, Leaflet 2.0 et pigeon-maps. Elles sont consignées avec le statut
unsupported. -
Incident de campagne : à 08:09 UTC, j’ai lancé deux captures d’écran Chrome pendant la campagne pour vérifier les graphiques. Les deux runs en cours à ce moment (leaflet-1 WebP desktop et pigeon-maps WebP mobile) étaient des warm-up, exclus des statistiques. Aucune mesure n’a été relancée.
-
Défauts corrigés pendant le pilote, avant la campagne :
- conteneur MapLibre écrasé par le CSS de la librairie (
.maplibregl-map { position: relative }) ; rendercompleted’OpenLayers capté avant l’ajout asynchrone des couches vectorielles ;- webfonts d’ol-mapbox-style absentes du cache de rejeu.
Les données du pilote ne sont pas publiées.
- conteneur MapLibre écrasé par le CSS de la librairie (
6. Analyse
Hypothèse a priori. Avant la campagne, j’attendais :
- au chargement, pigeon-maps ≫ Leaflet > MapLibre en légèreté ;
- des tuiles PNG lourdes, au point que le poids total devienne comparable avec la navigation.
Les mesures confirment l’ordre, mais pas l’ampleur. En JavaScript, pigeon-maps est bien très loin devant : 7,4 ko de surcoût contre 38,3 ko pour Leaflet et 354,0 ko pour MapLibre. Mais au premier chargement total, les tuiles dominent, et pigeon-maps ne pèse que 8 % de moins que Leaflet (431,1 contre 467,7 ko en PNG mobile). La seconde partie de l’hypothèse va plus loin que prévu : après deux zooms, le vectoriel ne fait pas que rattraper le raster PNG, il passe dessous. 788,4 ko pour MapLibre contre 936,9 ko pour Leaflet sur mobile ; 903,0 contre 2 403,8 ko sur desktop. Le premier chargement de MapLibre en vectoriel coûte pourtant 1,7× celui de Leaflet en PNG (788,4 contre 467,7 ko). Le WebP rapproche le raster du vectoriel sur mobile (792,2 ko pour Leaflet), mais pas sur desktop (1 686,3 ko).
Pourquoi le vectoriel gagne sur la durée. Une tuile raster contient des pixels pour un niveau de zoom donné : chaque niveau en exige de nouvelles. Une tuile vectorielle contient de la géométrie, que le moteur redessine à n’importe quelle échelle. Tant que la source n’a pas de données plus détaillées au niveau suivant, aucune requête n’est nécessaire. Le coût fixe du vectoriel (JavaScript, sprites, glyphes) est payé une fois et se met en cache longtemps.
Pourquoi MapLibre est lent même en cache. Derrière les 413,2 ko de JavaScript transférés, MapLibre et son worker représentent environ 1,6 Mo décompressés (taille des fichiers du build), à analyser et exécuter. Il faut aussi créer un contexte WebGL et démarrer les workers. Sur processeur ralenti, c’est ce coût de démarrage, et non le réseau, qui fixe le délai de la seconde visite.
Comparaison avec les chiffres publiés ailleurs. Des comparatifs de blogs citent Leaflet autour de 42 ko gzip et MapLibre autour de 200 à 210 ko gzip [Source : comparatif Geoapify]. Notre surcoût Leaflet (38,3 ko en brotli, wrapper React compris) est cohérent avec le premier chiffre. Pour MapLibre, nous mesurons 354,0 ko : ces comparatifs ne précisent pas leur version, et MapLibre 6 charge son worker dans un fichier séparé, compté ici. L’écart reste non expliqué en détail. Une étude académique de 2025 trouve MapLibre plus lent que Leaflet et OpenLayers pour afficher des objets vectoriels (lignes, polygones GeoJSON) [Source : ICA Abstracts 2025]. Elle mesure un autre scénario que le nôtre, des tuiles de fond, mais son constat d’une initialisation coûteuse rejoint nos temps « carte prête ».
7. Limites et menaces à la validité
- « Carte prête » n’a pas la même définition partout.
idle(MapLibre) etrendercomplete(OpenLayers) attendent la fin du fondu d’apparition des tuiles. L’événementloadde Leaflet est émis dès que les images sont chargées, avant leur fondu CSS. L’écart desktop entre Leaflet (~80 ms) et OpenLayers ou MapLibre (~375 à 460 ms) en est partiellement un artefact. Les poids ne sont pas concernés. - Un seul style vectoriel. Le zoom gratuit tient au niveau maximal des sources (14) du style ublo. Un style dont les sources vont jusqu’au niveau 16 donnerait un autre résultat.
- Une seule zone, deux zooms. Une session réelle comporte des déplacements latéraux, qui coûtent des tuiles dans toutes les configurations, vectoriel compris.
- Réseau simulé. La latence est ajoutée par requête côté serveur, sans négociation TCP/TLS réelle ni démarrage lent de TCP. Le débit est partagé de façon idéale. Les temps mobiles sont un ordre de grandeur, pas une prédiction pour un vrai téléphone.
- Processeur simulé. Un M2 ralenti ×4 n’est pas un téléphone milieu de gamme ; le GPU, lui, n’est pas ralenti, ce qui avantage MapLibre.
- Configuration par défaut. Aucune optimisation n’a été appliquée : fondus,
paddingde maplibre-gl-leaflet, webfonts d’ol-mapbox-style, chargement dynamique du code. Des réglages documentés changeraient certains chiffres, notamment les 327,3 ko de webfonts d’OpenLayers. - WebP lossy q=80 uniquement. Le WebP sans perte n’a pas été testé ; il pourrait inverser le résultat sur l’overlay de pistes.
- Versions datées : Leaflet 2.0 est une alpha ; MapLibre 6.14.0 ; tuiles OSM enregistrées le 12 octobre 2026.
- Une seule machine (Apple M2), un seul navigateur (Chromium 156).
- Biais de l’auteur : le style vectoriel est celui de l’équipe de l’auteur.
8. Recommandation
- Carte d’agrément, peu de navigation, budget JavaScript serré : pigeon-maps (surcoût JavaScript de 7,4 ko, seconde visite en 312 ms sur mobile) ou Leaflet. Servez le fond en WebP (−83 ko au premier chargement mobile), mais gardez l’overlay transparent en PNG.
- Leaflet 1.9 ou 2.0 : aucune différence de tuiles ni de temps significative, et 3,5 ko de JavaScript d’écart. Le choix relève de la migration ESM, pas de la performance.
- Carte que l’on explore : MapLibre GL JS en vectoriel, ou react-map-gl, qui n’ajoute que 4,4 ko. Sur desktop, la session complète pèse 2,7× moins que Leaflet en PNG (903,0 contre 2 403,8 ko). Le prix est un affichage plus tardif sur mobile (1 895 contre 1 066 ms).
- maplibre-gl-leaflet n’est pas une étape intermédiaire intéressante : il cumule le JavaScript des deux librairies (452,2 ko) et charge plus de tuiles. Il ne se justifie que pour garder un écosystème de plugins Leaflet.
- OpenLayers en vectoriel donne la session la plus légère (755,3 ko), à condition de régler ses webfonts. Ses 22 img/s au zoom sur mobile le réservent aux cas où la fluidité compte peu.
9. Données brutes
| Fichier | Contenu |
|---|---|
raw.csv | Toutes les mesures, une ligne par mesure, warm-up et combinaisons non supportées inclus |
summary.csv | Statistiques par (solution, tuiles, profil, métrique) : n, médiane, moyenne, écart-type, CV, min, max, p95, p99 |
summary.md | Tableaux complets générés par analyze |
environment.json | Machine, OS, versions, rendu WebGL, dates de campagne |
protocol.md | Fiche de cadrage validée et journal des décisions |
| Harnais | Code source et procédure de reproduction |
Colonnes de raw.csv :
| Colonne | Contenu |
|---|---|
run_id | identifiant d’un run (toutes ses métriques le partagent) |
timestamp | début du run, ISO 8601 UTC |
solution, solution_version | baseline, leaflet-1, leaflet-2, pigeon-maps, leaflet-maplibre, openlayers, maplibre-gl, react-map-gl et versions exactes |
scenario | type de tuiles : png, webp, vector, none (baseline) |
load | profil : mobile ou desktop |
iteration, phase | numéro de répétition ; warmup (écarté à l’analyse) ou measure |
metric, value, unit | une métrique par ligne. Préfixe cold. (1er chargement), zoom. ou warm. (avec cache). *.bytes.<catégorie> et *.req.<catégorie> : octets et requêtes par catégorie (app-js, tiles-osm, tiles-pistes, vector-planet, vector-contours, vector-winter, raster-hillshade, style, tilejson, sprite, glyphs, webfonts…). missing : réponses absentes du cache de rejeu |
status, error | ok, error, timeout ou unsupported, et message court |
Annexe — Reproduire les résultats
Prérequis
- macOS sur Apple Silicon (campagne publiée : MacBook M2). Linux devrait fonctionner mais n’a pas été testé : retirer
--use-angle=metaldansscripts/run.mjset vérifier que le rendu WebGL n’est pas logiciel (le script s’arrête s’il détecte SwiftShader). - Node.js 24 et npm (campagne publiée : Node 24.21.0).
openssl(présent par défaut sur macOS) pour le certificat du serveur de rejeu.- Environ 600 Mo de disque (dépendances npm et Chromium de Playwright).
- Accès réseau uniquement pour l’installation et l’enregistrement ; les mesures se font hors ligne.
- Aucune clé d’API, aucun service payant.
Récupérer le harnais
Le dépôt du blog est privé : les fichiers se téléchargent depuis le site.
BASE=https://learn.couble.eu/benchmarks/2026-10-12-benchmark-librairies-cartes-web
mkdir -p bench-cartes && cd bench-cartes
curl -fsSL "$BASE/harness/FILES.txt" | while read -r f; do
curl -fsSL --create-dirs -o "$f" "$BASE/harness/$f"
done
Installer les dépendances
bash setup.sh # curl ne conserve pas le bit d'exécution
setup.sh installe les dépendances épinglées (npm ci) et le Chromium de Playwright 1.64.0. Il génère aussi un certificat auto-signé (certs/) et construit les pages de test (dist/). Durée : 1 à 2 minutes selon la connexion.
Constituer le cache de rejeu
# 1. Passe hors ligne : liste les tuiles raster que les pages demandent réellement (~4 min)
node scripts/run.mjs --discover --reps 1 --warmup 0
# 2. Téléchargement unique de ces tuiles (OSM + OpenSnowMap), séquentiel, 250 ms entre requêtes (~2 min)
npm run prefetch
# 3. Relancer 1 et 2 jusqu'à ce que la découverte annonce « 0 tuiles raster distinctes »
# 4. Génération des tuiles WebP (q=80) à partir des PNG
npm run webp
# 5. Enregistrement du style vectoriel ublo et des webfonts d'ol-mapbox-style (~1 min, deux fois)
npm run record-vector && npm run record-vector
L’étape 2 envoie environ 150 requêtes à tile.openstreetmap.org et autant à tiles.opensnowmap.org. La tile usage policy d’OpenStreetMap interdit ce type de téléchargement automatisé : ne le répétez pas en boucle. Les tuiles évoluent : un cache reconstitué plus tard ne contiendra pas exactement les mêmes octets.
Lancer le benchmark
npm run env # écrit results/environment.json
npm run run # 456 runs, ~46 min sur M2, résultats ajoutés à results/raw.csv
node scripts/capture-env.mjs --end # date de fin de campagne
Fermez les autres applications, branchez la machine sur secteur et ne vous en servez pas pendant la campagne. Options utiles de scripts/run.mjs : --reps N, --warmup N, --solutions leaflet-1,maplibre-gl, --tiles png,webp,vector,none, --profiles mobile,desktop, --seed N, --out chemin.csv. Run court de vérification (~4 min) :
node scripts/run.mjs --reps 1 --warmup 0 --out results/smoke.csv
Analyser
npm run analyze # results/raw.csv → results/summary.csv + summary.md
node scripts/charts.mjs # results/summary.csv → charts/*.svg
Pour analyser les résultats publiés, placez raw.csv dans results/ puis lancez npm run analyze : vous obtiendrez les mêmes summary.csv et summary.md. Sur une autre machine, les poids doivent être très proches, à la variation près des tuiles OSM entre deux dates. Les temps, eux, dépendent du processeur, du GPU et de la version de Chromium : seuls les écarts relatifs entre solutions sont comparables.
Dépannage
- « rendu WebGL logiciel détecté » : Chromium n’a pas accès au GPU. Sur macOS, vérifier les options
--use-angle=metal --enable-gpu; ailleurs, adapterLAUNCH.argsdansscripts/run.mjs. - Timeouts sur les pages MapLibre en profil mobile : presque toujours des tuiles absentes du cache (le serveur répond 404). Relancer la découverte et le téléchargement (étapes 1 à 3). La métrique
missingcompte ces absences par run. - Ports 8443 ou 8790 occupés : le serveur de rejeu les utilise ; libérer ces ports.