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

MachineApple M5, 10 cœurs, 24 Gio, darwin 25.2.0 arm64
Nodev23.3.0 · esbuild 0.28.2 · gzip niveau 9
NavigateurChromium headless (Playwright), page en isolation cross-origin
Serveurlivekit/livekit-server:latest de docker-compose.yml, en local
Trajet réseauloopback pour les deux côtés, toujours (scénario 17)
Horloge de la pagerésolution 5,0 µs vérifiée à l’exécution
Mesures2026-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é.

Consommateurminifiégzip
aucun import (plancher)35 o55 o
livekit-client seul543,2 kio142,4 kio
livekit-client + @legba-core/realtime/client550,0 kio144,4 kio
… + un composant de @legba-core/ui615,1 kio163,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.

MesureLiveKit natifLegbaΔΔ %IC 95 %verdict
démarrage à froid380,3 ms338,6 ms−41,7 ms−11,0 %[−99,8 ; +45,9] msnon concluant
démarrage à chaud233,3 ms235,0 ms+1,7 ms+0,7 %[−45,6 ; +49,8] msnon concluant
construction du client0,20 ms0,24 ms+0,04 ms+20,0 %[−0,03 ; +0,14] msnon 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).
  • LegbasendData(objet) / événement dataReceived.
MesureréférenceLegbaΔΔ %IC 95 %verdict
1er message d’une session (à froid) vs brut1,87 ms1,77 ms−0,10 ms−5,3 %[−1,34 ; +0,41] msnon concluant
à chaud, charge courte, vs brut0,78 ms0,69 ms−0,08 ms−10,3 %[−0,27 ; +0,07] msnon concluant
à chaud, charge courte, vs LiveKit + JSON0,62 ms0,69 ms+0,07 ms+12,1 %[−0,06 ; +0,20] msnon concluant
à chaud, 4 kio, vs brut0,90 ms0,87 ms−0,03 ms−2,8 %[−0,22 ; +0,06] msnon concluant
à chaud, 4 kio, vs LiveKit + JSON0,82 ms0,87 ms+0,05 ms+6,1 %[−0,03 ; +0,14] msnon 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.

CheminLiveKit brutLiveKit + JSONLegbaΔ vs brutΔ vs JSON équivalentIC 95 % (vs JSON)
émission, charge courte0,04 µs0,46 µs0,56 µs+0,51 µs+0,10 µs (+20,7 %)[+0,06 ; +0,14] µs
réception, charge courte0,26 µs0,60 µs0,66 µs+0,40 µs+0,06 µs (+10,0 %)[−0,03 ; +0,11] µs
émission, 4 kio0,05 µs2,88 µs3,15 µs+3,10 µs+0,27 µs (+9,4 %)[+0,05 ; +0,48] µs
réception, 4 kio0,26 µs1,48 µs1,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 :

  1. 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.
  2. 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 natifLegbaΔΔ %IC 95 %
tas ajouté par une connexion établie784,7 kio796,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

DimensionSurcoû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 octetmesuré
Bundle, coût à vide (ui, sideEffects: true)+167,3 kio gzipmesuré, assumé
Démarrage (à froid et à chaud)aucun écart établimesuré
Latence bout en boutaucun écart établi, seuil de 10 % non décidable à cette échellemesuré
Coût CPU propre, émission+0,10 µs (court) / +0,27 µs (4 kio)mesuré
Coût CPU propre, réceptionnon distinguable de zéromesuré
Tas JS par connexion+11,9 kio (+1,5 %)mesuré
Empreinte mémoire du processusnon 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 %.