fr

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.

SolutionTuiles1er 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.9PNG467,797,5469,2936,91 066334
Leaflet 1.9WebP384,397,5408,0792,2996344
Leaflet 2.0 αPNG462,394,0469,2931,51 040320
pigeon-mapsPNG431,166,6625,01 056,11 098312
pigeon-mapsWebP347,766,6551,9899,51 038312
OpenLayersPNG485,6119,9469,2954,71 401647
OpenLayersvectoriel755,3195,20,0755,32 1081 164
maplibre-gl-leafletvectoriel902,7452,20,0902,72 043841
MapLibre GL JSPNG786,2413,2491,51 277,81 716758
MapLibre GL JSvectoriel788,4413,20,0788,41 895808
react-map-glvectoriel792,8417,60,0792,81 915823
Baseline : page React vide—59,459,1——475263

« 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 winter de tiles.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

SolutionVersionTuiles testéesRemarques
Baseline : page React videReact 19.3.0, Vite 8.3.4—isole le coût du cadre commun
Leaflet 1.x1.9.4PNG, WebPstable actuelle
Leaflet 2.02.0.0-alpha.1PNG, WebPréécriture ESM, toujours en alpha à date [Source : Leaflet 2.0 alpha, téléchargements]
pigeon-maps0.22.1PNG, WebPcomposant React sans dépendance
Leaflet + maplibre-gl-leaflet1.9.4 + 0.1.4 + maplibre-gl 6.14.0PNG, WebP, vectorielcarte Leaflet dont le rendu est délégué à MapLibre
OpenLayersol 10.11.0 (+ ol-mapbox-style 13.5.1 en vectoriel)PNG, WebP, vectoriel
MapLibre GL JS6.14.0PNG, WebP, vectorielrendu WebGL
react-map-gl8.1.3 + maplibre-gl 6.14.0PNG, WebP, vectorielcomposant 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 :

  1. Chargement à froid (cache HTTP vide) jusqu’à « carte prête », puis 1 s d’attente.
  2. 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.animate seul vaut 1 s. On attend « carte prête » après chaque palier.
  3. 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, ETag et les autres en-têtes sont rejoués tels qu’enregistrés. Côté tuiles OSM, c’est max-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.app et 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 missing vaut 0 partout.

4.3 Métriques

MétriqueDéfinitionUnitéSensMesure
1er chargementOctets transférés, cache vide, jusqu’à « carte prête » + 1 sko↓serveur de rejeu
Avec cacheIdem, seconde visiteko↓serveur de rejeu
JSPart JavaScript du 1er chargement ; surcoût = différence avec la baselineko↓serveur de rejeu
Tuiles initiales / zoomOctets 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 à partko, nombre↓serveur de rejeu, catégorie par URL
Session totale1er chargement + octets du zoomko↓dérivée
Carte prêteNavigation → 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 / LCPFirst Contentful Paint / Largest Contentful Paintms↓PerformanceObserver
TBTTotal Blocking Time : somme des (durée − 50 ms) des tâches longues jusqu’à « carte prête »ms↓PerformanceObserver (longtask)
Tas JSMémoire JavaScript utilisée à « carte prête », thread principalMo↓Chrome DevTools Protocol (CDP) Performance.getMetrics
FPS au zoomImages par seconde (requestAnimationFrame) pendant les animations de zoomimg/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-gpu et s’arrête s’il détecte SwiftShader.
  • Profil mobile : 390×844 px, deviceScaleFactor 3, é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, deviceScaleFactor 2, 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

Octets transférés au premier chargement puis pendant deux zooms, par solution et type de tuiles, profil mobile

Solution (mobile)Tuiles1er chargement (ko)Tuiles initiales (ko)Tuiles zoom (ko)Session totale (ko)
Leaflet 1.9PNG467,7364,3 (24 req.)469,2 (44 req.)936,9
Leaflet 1.9WebP384,3280,8 (24 req.)408,0 (44 req.)792,2
Leaflet 2.0 αPNG462,3364,3 (24 req.)469,2 (44 req.)931,5
Leaflet 2.0 αWebP378,9280,8 (24 req.)408,0 (44 req.)786,8
pigeon-mapsPNG431,1364,3 (24 req.)625,0 (60 req.)1 056,1
pigeon-mapsWebP347,7280,8 (24 req.)551,9 (60 req.)899,5
maplibre-gl-leafletPNG889,9423,2 (30 req.)508,2 (50 req.)1 398,1
maplibre-gl-leafletWebP801,4334,7 (30 req.)437,4 (50 req.)1 238,7
maplibre-gl-leafletvectoriel902,7207,0 (20 req.) + 229,0 de style0,0902,7
OpenLayersPNG485,6364,3 (24 req.)469,2 (44 req.)954,7
OpenLayersWebP402,2280,8 (24 req.)408,0 (44 req.)810,1
OpenLayersvectoriel755,3137,4 (13 req.) + 421,2 de style0,0755,3
MapLibre GL JSPNG786,2364,3 (24 req.)491,5 (46 req.)1 277,8
MapLibre GL JSWebP702,8280,8 (24 req.)421,7 (46 req.)1 124,6
MapLibre GL JSvectoriel788,4137,4 (13 req.) + 229,0 de style0,0788,4
react-map-glPNG790,6364,3 (24 req.)491,5 (46 req.)1 282,2
react-map-glWebP707,2280,8 (24 req.)421,7 (46 req.)1 129,0
react-map-glvectoriel792,8137,4 (13 req.) + 229,0 de style0,0792,8

Sur desktop, l’écran plus grand multiplie les tuiles raster, et le vectoriel creuse l’écart :

Octets transférés au premier chargement puis pendant deux zooms, profil desktop

Solution (desktop)Tuiles1er chargement (ko)Tuiles zoom (ko)Session totale (ko)
Leaflet 1.9PNG946,71 457,1 (130 req.)2 403,8
Leaflet 1.9WebP712,6973,8 (130 req.)1 686,3
pigeon-mapsPNG910,12 359,4 (216 req.)3 269,5
OpenLayersPNG964,51 457,1 (130 req.)2 421,7
OpenLayersvectoriel869,90,0869,9
maplibre-gl-leafletvectoriel1 118,90,01 118,9
MapLibre GL JSPNG1 265,22 077,3 (174 req.)3 342,5
MapLibre GL JSvectoriel903,00,0903,0
react-map-glvectoriel907,40,0907,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 (padding par 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 PNGLeaflet WebPMapLibre vectorielOpenLayers vectoriel
Fond OSM247,8116,2——
Overlay OpenSnowMap116,5164,6——
Tuiles vectorielles planet——58,158,1
Courbes de niveau——18,118,1
Domaines skiables (winter-sports)——22,222,2
Relief raster (hillshade, WebP)——39,039,0
Style + TileJSON——15,715,7
Sprites——78,278,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 webfonts ou la métadonnée ol:webfonts permettent de pointer vers des fontes plus légères ou auto-hébergées.

5.3 JavaScript

SolutionRenduJS transféré (ko)Surcoût vs page React vide (ko)
pigeon-mapsraster66,67,4
Leaflet 2.0 αraster94,034,9
Leaflet 1.9raster97,538,3
OpenLayersraster119,960,7
OpenLayers + ol-mapbox-stylevectoriel195,2136,1
MapLibre GL JSraster et vectoriel413,2354,0
react-map-glraster et vectoriel417,6358,4
maplibre-gl-leafletraster et vectoriel452,2393,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

Temps jusqu'à « carte prête », cache vide, profil mobile

Solution (mobile)TuilesCarte prête (ms, p95)Avec cache (ms, p95)FCP (ms)LCP (ms)TBT (ms)Tas JS (Mo)
Baseline—475 (487)263 (275)——02,3
Leaflet 1.9PNG1 066 (1 081)334 (358)5841 0046 ⚠3,4
Leaflet 1.9WebP996 (1 006)344 (355)5908549 ⚠3,4
Leaflet 2.0 αPNG1 040 (1 057)320 (334)56297203,2
pigeon-mapsPNG1 098 (1 132)312 (325)52484603,1
pigeon-mapsWebP1 038 (1 062)312 (324)52879603,1
OpenLayersPNG1 401 (1 419)647 (660)6186185 ⚠4,1
OpenLayersvectoriel2 108 (2 147)1 164 (1 240)69269221017,7 ⚠
maplibre-gl-leafletvectoriel2 043 (2 067)841 (859)8648641209,5
MapLibre GL JSPNG1 716 (1 728)758 (767)8008001 ⚠6,4 ⚠
MapLibre GL JSWebP1 619 (1 637)733 (784)7887880 ⚠8,2 ⚠
MapLibre GL JSvectoriel1 895 (1 913)808 (823)77877855 ⚠13,6 ⚠
react-map-glvectoriel1 915 (1 932)823 (851)79279255 ⚠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.

Temps jusqu'à « carte prête », cache vide, profil desktop

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)TuilesDeux zooms jusqu’à « prête » (ms)FPS pendant l’animation
Leaflet 1.9PNG78460
pigeon-mapsPNG1 27665
OpenLayersPNG1 66762
OpenLayersvectoriel1 27422
MapLibre GL JSPNG2 19361
MapLibre GL JSvectoriel1 98061

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 }) ;
    • rendercomplete d’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.

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) et rendercomplete (OpenLayers) attendent la fin du fondu d’apparition des tuiles. L’événement load de 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, padding de 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

FichierContenu
raw.csvToutes les mesures, une ligne par mesure, warm-up et combinaisons non supportées inclus
summary.csvStatistiques par (solution, tuiles, profil, métrique) : n, médiane, moyenne, écart-type, CV, min, max, p95, p99
summary.mdTableaux complets générés par analyze
environment.jsonMachine, OS, versions, rendu WebGL, dates de campagne
protocol.mdFiche de cadrage validée et journal des décisions
HarnaisCode source et procédure de reproduction

Colonnes de raw.csv :

ColonneContenu
run_ididentifiant d’un run (toutes ses métriques le partagent)
timestampdébut du run, ISO 8601 UTC
solution, solution_versionbaseline, leaflet-1, leaflet-2, pigeon-maps, leaflet-maplibre, openlayers, maplibre-gl, react-map-gl et versions exactes
scenariotype de tuiles : png, webp, vector, none (baseline)
loadprofil : mobile ou desktop
iteration, phasenuméro de répétition ; warmup (écarté à l’analyse) ou measure
metric, value, unitune 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, errorok, 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=metal dans scripts/run.mjs et 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, adapter LAUNCH.args dans scripts/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 missing compte ces absences par run.
  • Ports 8443 ou 8790 occupés : le serveur de rejeu les utilise ; libérer ces ports.