overhead-legba-vs-livekit
Overhead de Legba face à LiveKit natif — LGB-054
Nature de ce document
Rapport chiffré, pas une opinion. Chaque nombre vient d’une exécution
réelle, reproductible par la commande indiquée. Le harnais est livré comme
code exécutable dans examples/overhead-bench
— aucun chiffre n’a été recopié à la main sans provenance.
Comme LGB-037, ce rapport distingue explicitement trois états et ne les mélange jamais (scénario 9) :
- mesuré — relevé par une exécution de ce harnais, série complète
enregistrée dans
examples/overhead-bench/results/. - estimé — déduit d’une mesure voisine, jamais relevé directement ; dit comme tel, avec son raisonnement.
- non mesuré — pas mesurable honnêtement avec cet outillage ; la raison est donnée, aucun chiffre n’est inventé à la place.
Reproduire
pnpm --filter overhead-bench run bench:bundle # tailles de bundle
pnpm --filter overhead-bench run bench:runtime # démarrage, latence, CPU, mémoire
pnpm --filter overhead-bench exec node scripts/analyze.mjs # rejoue l'analyse seule
La pile LiveKit de docker-compose.yml doit tourner (ws://localhost:7880).
Conditions de la mesure
| Machine | Apple M5, 10 cœurs, 24 Gio, darwin 25.2.0 arm64 |
| Node | v23.3.0 · esbuild 0.28.2 · gzip niveau 9 |
| Navigateur | Chromium headless (Playwright), page en isolation cross-origin |
| Serveur | livekit/livekit-server:latest de docker-compose.yml, en local |
| Trajet réseau | loopback pour les deux côtés, toujours (scénario 17) |
| Horloge de la page | résolution 5,0 µs vérifiée à l’exécution |
| Mesures | 2026-09-19 — results/bundle-size.json, results/runtime.json |
Isolement de la machine — déclaré, pas garanti (scénario 18). Le harnais recense les autres charges de test avant de démarrer. Au moment de la mesure retenue, 15 processus de test concurrents étaient actifs (11 Playwright, 1 Web Test Runner, 3 autres), et la charge moyenne sur une minute valait 4,78 au départ pour 12,93 à l’arrivée. La pile LiveKit Docker est elle aussi partagée avec d’autres chantiers. Les mesures ne sont donc pas prises sur une machine isolée : c’est la première raison pour laquelle les écarts de latence bout en bout ne ressortent pas du bruit ci-dessous, et il fallait le dire plutôt que de présenter les chiffres comme propres.
Production des deux côtés (scénario 13). Les deux harnais sont
construits minifiés, avec process.env.NODE_ENV remplacé par
"production" et sans la condition d’export development de lit. Le
script échoue s’il retrouve un marqueur de mode développement
(Lit is in dev mode, litIssuedWarnings) dans un bundle mesuré : la
comparaison ne peut pas être lancée contre un build de développement.
1. Taille des bundles — mesuré
Build esbuild réel, bundle + minify + treeShaking, format ESM, cible
es2022, puis gzip niveau 9. La fixture « aucun import » sert de plancher
(35 octets minifiés) : tout ce qui dépasse est du code réellement embarqué.
| Consommateur | minifié | gzip |
|---|---|---|
| aucun import (plancher) | 35 o | 55 o |
livekit-client seul | 543,2 kio | 142,4 kio |
livekit-client + @legba-core/realtime/client | 550,0 kio | 144,4 kio |
… + un composant de @legba-core/ui | 615,1 kio | 163,6 kio |
Surcoût, par paquet et en cumulé (scénario 14) :
| Couche ajoutée | Δ minifié | Δ gzip | Δ gzip % |
|---|---|---|---|
@legba-core/realtime sur livekit-client | +6,87 kio | +2,00 kio | +1,4 % |
@legba-core/ui (lit inclus) par-dessus | +65,1 kio | +19,1 kio | +13,2 % |
cumulé realtime + ui | +72,0 kio | +21,1 kio | +14,8 % |
Lecture : la couche temps réel de Legba coûte 2 kio gzip au-dessus de
livekit-client, soit 1,4 % d’un bundle déjà dominé à 98,6 % par le SDK
LiveKit lui-même. La couche UI coûte 19 kio gzip de plus, dont l’essentiel
est lit — un consommateur qui ne veut pas de Web Components ne la paie
pas, comme le montre la section suivante.
Coût à vide et tree-shaking — mesuré (scénarios 15 et 16)
| Cas | Δ minifié vs plancher |
|---|---|
import "@legba-core/realtime/client" jamais utilisé | 0 octet |
import { createLegbaRoom } from … jamais appelé | 0 octet |
import "@legba-core/ui" jamais utilisé | +614,8 kio (167,3 kio gzip) |
@legba-core/realtime déclare sideEffects: false : esbuild retire
l’import entier et le journalise (« Ignoring this import because
packages/realtime/dist/client.js was marked as having no side effects »,
avertissement conservé dans results/bundle-size.json). Le coût à vide est
exactement zéro, pas « négligeable ».
@legba-core/ui déclare délibérément sideEffects: true :
l’enregistrement des custom elements EST un effet de bord, et l’avoir
oublié était un bug réel corrigé en LGB-033. La conséquence, chiffrée ici,
est qu’un import nu de ce paquet embarque tout — les 14 composants,
lit, livekit-client — soit 614,8 kio minifiés. Ce n’est pas un défaut à
corriger : c’est le prix, désormais connu, d’un paquet dont l’import doit
enregistrer des éléments. Ce que ce chiffre impose, c’est une règle de
documentation : un consommateur qui n’a besoin que de createLegbaRoom
doit importer @legba-core/realtime/client, jamais @legba-core/ui — et
il ne paie alors rien de la couche UI.
2. Temps de démarrage — mesuré, aucun écart établi
connect() → état connected, contre le même serveur et la même salle.
À froid : contexte navigateur neuf à chaque répétition (n = 10 par côté).
À chaud : page conservée, une mesure d’échauffement jetée (n = 15 par
côté). Les deux ne sont jamais mélangées (scénario 10). L’ordre des
deux configurations tourne à chaque répétition, sans quoi celle mesurée en
premier paierait seule le réveil du navigateur et du serveur.
| Mesure | LiveKit natif | Legba | Δ | Δ % | IC 95 % | verdict |
|---|---|---|---|---|---|---|
| démarrage à froid | 380,3 ms | 338,6 ms | −41,7 ms | −11,0 % | [−99,8 ; +45,9] ms | non concluant |
| démarrage à chaud | 233,3 ms | 235,0 ms | +1,7 ms | +0,7 % | [−45,6 ; +49,8] ms | non concluant |
| construction du client | 0,20 ms | 0,24 ms | +0,04 ms | +20,0 % | [−0,03 ; +0,14] ms | non concluant |
Le temps de connexion est dominé par les allers-retours de signalisation et la négociation ICE, côté serveur : l’intervalle interquartile vaut ~50 ms à chaud et ~100 ms à froid pour les deux côtés. La série à chaud est même franchement bimodale (deux paliers, ~230 ms et ~278 ms), ce qui est un comportement du serveur, pas de la bibliothèque cliente (scénario 19). Aucun surcoût de démarrage attribuable à Legba n’est établi.
Piège méthodologique rencontré, et corrigé. Une première analyse fondée sur l’écart absolu médian concluait à un surcoût établi de +45 ms (+19,4 %) au démarrage à chaud. C’était faux : sur une série bimodale, la médiane tombe dans l’un des deux paliers et l’écart absolu médian devient minuscule, ce qui fait passer du bruit pour un résultat. Le critère a été remplacé par un intervalle de confiance à 95 % sur la différence des médianes, par bootstrap (tirage semé, donc reproductible), et l’écart a disparu. Le cas est figé en test (
scripts/stats.test.mjs).
3. Latence — mesurée, aucun dépassement établi
Publication → réception d’un message de données, émetteur et récepteur dans la même page, donc une seule horloge : la mesure ne dépend d’aucune synchronisation entre machines. Même salle, même serveur, même loopback des deux côtés (scénarios 7 et 17). Charges utiles de longueur identique en octets sur le fil pour les trois configurations. 5 sessions par configuration, 8 messages chacune : premier message de chaque session compté « à froid » (n = 5), les suivants « à chaud » (n = 35).
Trois configurations, pour séparer ce qui vient de Legba de ce qui vient de LiveKit (scénario 19) :
- LiveKit brut — octets pré-encodés hors zone chronométrée : la latence inhérente au serveur, plancher absolu.
- LiveKit + JSON équivalent — le même LiveKit, chargé du travail que
Legba fait pour son appelant (JSON +
TextEncoder/TextDecoder) : la référence honnête face àsendData(objet). - Legba —
sendData(objet)/ événementdataReceived.
| Mesure | référence | Legba | Δ | Δ % | IC 95 % | verdict |
|---|---|---|---|---|---|---|
| 1er message d’une session (à froid) vs brut | 1,87 ms | 1,77 ms | −0,10 ms | −5,3 % | [−1,34 ; +0,41] ms | non concluant |
| à chaud, charge courte, vs brut | 0,78 ms | 0,69 ms | −0,08 ms | −10,3 % | [−0,27 ; +0,07] ms | non concluant |
| à chaud, charge courte, vs LiveKit + JSON | 0,62 ms | 0,69 ms | +0,07 ms | +12,1 % | [−0,06 ; +0,20] ms | non concluant |
| à chaud, 4 kio, vs brut | 0,90 ms | 0,87 ms | −0,03 ms | −2,8 % | [−0,22 ; +0,06] ms | non concluant |
| à chaud, 4 kio, vs LiveKit + JSON | 0,82 ms | 0,87 ms | +0,05 ms | +6,1 % | [−0,03 ; +0,14] ms | non concluant |
Aucun dépassement de 10 % n’est établi, et aucune absence de dépassement ne l’est non plus. Les estimations ponctuelles vont de −10,3 % à +12,1 % ; tous les intervalles contiennent zéro. Sur un trajet de ~0,7 ms avec un IQR de 0,3 à 0,4 ms, le seuil de 10 % vaut ~0,07 ms, soit un cinquième de la dispersion : le critère d’acceptation d’origine n’est pas décidable à cette échelle avec cet outillage, sur une machine partagée (scénario 12). Le déclarer « conforme » sur une estimation ponctuelle favorable serait une invention ; le déclarer « défaut » sur l’estimation défavorable en serait une autre.
C’est précisément pour trancher malgré ce bruit que la mesure suivante a été ajoutée.
4. Coût CPU propre à la bibliothèque — mesuré, et décisif
Le transport est neutralisé (publishData remplacé par une promesse vide
sur une connexion pourtant réelle), et la réception est déclenchée sans
réseau. Ce qui reste est exactement le travail que la bibliothèque fait
autour de LiveKit : sérialisation, validation, adaptation d’événement. Un
appel coûtant moins qu’un tick d’horloge, les appels sont chronométrés par
lots de 500 puis divisés — 24 lots par session, 3 sessions, n = 60 après
échauffement du JIT.
| Chemin | LiveKit brut | LiveKit + JSON | Legba | Δ vs brut | Δ vs JSON équivalent | IC 95 % (vs JSON) |
|---|---|---|---|---|---|---|
| émission, charge courte | 0,04 µs | 0,46 µs | 0,56 µs | +0,51 µs | +0,10 µs (+20,7 %) | [+0,06 ; +0,14] µs |
| réception, charge courte | 0,26 µs | 0,60 µs | 0,66 µs | +0,40 µs | +0,06 µs (+10,0 %) | [−0,03 ; +0,11] µs |
| émission, 4 kio | 0,05 µs | 2,88 µs | 3,15 µs | +3,10 µs | +0,27 µs (+9,4 %) | [+0,05 ; +0,48] µs |
| réception, 4 kio | 0,26 µs | 1,48 µs | 1,60 µs | +1,34 µs | +0,12 µs (+8,1 %) | [−0,01 ; +0,18] µs |
Ce tableau dit deux choses très différentes, et c’est tout l’intérêt de la troisième configuration :
- Face à LiveKit brut, Legba coûte +0,51 µs à l’émission d’un message court et +3,10 µs pour 4 kio. Mais cet écart n’est pas un surcoût de Legba : c’est le coût du JSON, que l’application paierait de sa poche pour envoyer un objet plutôt que des octets. Les pourcentages correspondants (+1288 %, +6200 %) sont calculés sur une base de 0,04 µs et ne désignent aucun défaut — c’est exactement le piège du scénario 11, et le harnais refuse d’appliquer le seuil de 10 % à ces lignes.
- Face à une implémentation native équivalente, le surcoût réellement imputable à Legba vaut +0,10 µs par émission courte et +0,27 µs pour 4 kio (écarts établis), et n’est pas distinguable de zéro à la réception. Rapporté au trajet complet d’un message (~0,7 ms), cela représente 0,014 % — trois ordres de grandeur sous le seuil de 10 %.
C’est le résultat qui permet de conclure sur la latence : le surcoût de Legba existe, il est mesuré, il vaut quelques dixièmes de microseconde, et il ne peut pas produire les 10 % redoutés sur un trajet à la milliseconde.
5. Mémoire — partiellement mesuré
Mesuré : le tas JavaScript. Ramasse-miettes forcé puis
Runtime.getHeapUsage par CDP, avant et après connexion, contexte neuf à
chaque répétition (n = 8 par côté).
| LiveKit natif | Legba | Δ | Δ % | IC 95 % | |
|---|---|---|---|---|---|
| tas ajouté par une connexion établie | 784,7 kio | 796,7 kio | +11,9 kio | +1,5 % | [+10,2 ; +21,8] kio |
Le surcoût est établi et vaut ~12 kio par connexion : le suivi des
participants distants, les tables d’écouteurs enveloppés, l’instance
LegbaClientRoom. À rapporter aux ~785 kio qu’alloue déjà une connexion
LiveKit nue, et aux 1 785 kio de tas que pèse la page avant toute
connexion.
Non mesuré : l’empreinte mémoire réelle du processus. Elle n’est pas
mesurable honnêtement avec cet outillage, pour une raison précise : la plus
grosse partie de la mémoire d’un client WebRTC ne vit pas dans le tas
JavaScript. Les PeerConnection, les tampons de jitter, les codecs et les
pipelines média sont alloués côté natif du navigateur, dans des processus
distincts (réseau, GPU, utilitaire audio) partagés par tous les onglets.
Runtime.getHeapUsage ne les voit pas ; la RSS du processus navigateur les
voit mais ne les attribue pas à une connexion donnée. Publier une RSS
comme « mémoire de Legba » serait un chiffre faux présenté comme précis.
Une mesure honnête demanderait un autre outillage (un client hors
navigateur, ou une instrumentation WebRTC dédiée) — hors du périmètre de ce
billet.
Verdict
| Dimension | Surcoût de Legba | État |
|---|---|---|
| Bundle, couche temps réel | +2,00 kio gzip (+1,4 %) | mesuré |
| Bundle, couche UI en plus | +19,1 kio gzip (+13,2 %) | mesuré |
Bundle, coût à vide (realtime) | 0 octet | mesuré |
Bundle, coût à vide (ui, sideEffects: true) | +167,3 kio gzip | mesuré, assumé |
| Démarrage (à froid et à chaud) | aucun écart établi | mesuré |
| Latence bout en bout | aucun écart établi, seuil de 10 % non décidable à cette échelle | mesuré |
| Coût CPU propre, émission | +0,10 µs (court) / +0,27 µs (4 kio) | mesuré |
| Coût CPU propre, réception | non distinguable de zéro | mesuré |
| Tas JS par connexion | +11,9 kio (+1,5 %) | mesuré |
| Empreinte mémoire du processus | — | non mesuré, raison ci-dessus |
Aucun défaut de latence n’est constaté. Le seul dépassement de 10 % qu’on aurait pu croire établi — +19,4 % au démarrage à chaud — était un artefact du critère statistique, corrigé et figé en test.
Amendement proposé au critère d’acceptation
Le critère d’origine (« un dépassement de 10 % sur la latence est un défaut ») reste juste sur le principe mais n’est pas décidable sur un trajet loopback de ~0,7 ms mesuré depuis une machine de développement partagée : 10 % y valent 0,07 ms, contre 0,3 à 0,4 ms de dispersion. Proposition, à valider par l’utilisateur :
Le seuil de 10 % s’apprécie sur le coût CPU propre à la bibliothèque (transport neutralisé, mesure à faible bruit), pas sur la latence bout en bout. Sur la latence bout en bout, l’exigence devient : aucun dépassement établi, au sens d’un intervalle de confiance à 95 % sur la différence des médianes entièrement au-delà de +10 %.