HTTP/3 et QUIC#

HTTP/3 représente une rupture profonde dans l’histoire des protocoles web. Alors que HTTP/1.1 et HTTP/2 s’appuient tous deux sur TCP comme couche de transport, HTTP/3 abandonne TCP au profit de QUIC, un protocole de transport nouvelle génération développé initialement par Google, puis standardisé par l’IETF (RFC 9000 pour QUIC, RFC 9114 pour HTTP/3). Cette transition répond à des limitations fondamentales que TCP impose à toutes les couches qui l’utilisent, limitations qui deviennent critiques à l’ère des réseaux mobiles, des connexions instables et des latences élevées.

Ce chapitre expose les problèmes architecturaux de HTTP/2 sur TCP, décrit en détail le fonctionnement de QUIC, explique comment HTTP/3 se greffe sur QUIC, et compare les performances à l’aide de visualisations et de code Python.

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.lines as mlines
import seaborn as sns
import pandas as pd

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)

Les limites de HTTP/2 sur TCP#

HTTP/2 : une amélioration majeure mais incomplète#

HTTP/2 (RFC 7540, 2015) a apporté des avancées considérables par rapport à HTTP/1.1 : multiplexage des requêtes sur une seule connexion TCP, compression des en-têtes avec HPACK, push serveur, priorisation des flux. Sur papier, ces mécanismes éliminent les principaux goulots d’étranglement de HTTP/1.1 (files de requêtes, multiples connexions TCP, en-têtes répétitifs).

En pratique, HTTP/2 souffre d’un problème fondamental qu’il ne peut pas résoudre, car il est ancré dans la couche transport : le head-of-line blocking (HoL blocking) au niveau TCP.

Le head-of-line blocking au niveau TCP#

TCP garantit la livraison ordonnée et fiable des octets. Si un segment TCP est perdu, le récepteur ne peut pas livrer à l’application les segments suivants même s’ils sont déjà arrivés : il doit attendre la retransmission du segment manquant. Cette attente bloque tous les flux HTTP/2 multiplexés sur la connexion, même ceux qui n’ont aucun rapport avec le segment perdu.

Exemple concret de HoL blocking TCP

Imaginons trois requêtes HTTP/2 multiplexées sur une connexion TCP : images A, B, C. Leurs segments TCP sont entrelacés. Si un segment de l’image A est perdu, TCP bloque la livraison des segments des images B et C qui sont pourtant intacts en mémoire tampon. L’application attend la retransmission même si les données dont elle a besoin sont disponibles.

HTTP/2 résout le HoL blocking au niveau applicatif (plus besoin d’attendre qu’une requête HTTP/1.1 soit complète avant d’en envoyer une autre), mais introduit un HoL blocking au niveau transport qui n’existait pas de la même façon avec HTTP/1.1 (qui ouvrait plusieurs connexions TCP indépendantes).

La lenteur du handshake TCP+TLS#

Établir une connexion HTTPS nécessite deux handshakes successifs :

  1. Handshake TCP : 1 RTT (SYN → SYN-ACK → ACK)

  2. Handshake TLS 1.3 : 1 RTT (ClientHello → ServerHello+Certificate+Finished → Finished)

Total : 2 RTT avant que le premier octet de données puisse être transmis. Sur un réseau mobile avec 100 ms de RTT, cela représente 200 ms de délai incompressible avant toute donnée utile.

TLS 1.3 a introduit le 0-RTT resumption pour les sessions reprises, mais il reste limité (replay attacks, restrictions sur les requêtes non-idempotentes).

Ossification du protocole#

TCP est implémenté dans les systèmes d’exploitation (noyau Linux, Windows, macOS, iOS, Android). Toute modification de TCP nécessite des mises à jour de milliards d’équipements, un processus qui prend des années voire des décennies. De nombreux équipements réseau intermédiaires (pare-feux, NAT, middleboxes) inspectent ou modifient les paquets TCP, rendant toute extension risquée.

Hide code cell source

# Visualisation comparative : handshakes TCP+TLS vs QUIC
fig, axes = plt.subplots(1, 2, figsize=(14, 7))

def draw_handshake_tcp_tls(ax):
    """Diagramme séquentiel TCP + TLS 1.3 : nouvelle connexion"""
    ax.set_xlim(0, 10)
    ax.set_ylim(0, 12)
    ax.set_title("TCP + TLS 1.3\n(nouvelle connexion : 2 RTT)", fontsize=12, fontweight="bold")
    ax.axis("off")

    # Colonnes client/serveur
    ax.axvline(x=2, color="#2196F3", linewidth=2, ymin=0.05, ymax=0.95)
    ax.axvline(x=8, color="#F44336", linewidth=2, ymin=0.05, ymax=0.95)
    ax.text(2, 11.5, "Client", ha="center", fontsize=11, fontweight="bold", color="#2196F3")
    ax.text(8, 11.5, "Serveur", ha="center", fontsize=11, fontweight="bold", color="#F44336")

    # Messages TCP handshake
    msgs = [
        (10.8, 2, 8, "SYN", "#4CAF50"),
        (9.8,  8, 2, "SYN-ACK", "#4CAF50"),
        (8.8,  2, 8, "ACK  ← Fin TCP handshake (RTT 1)", "#4CAF50"),
        (7.5,  2, 8, "ClientHello", "#FF9800"),
        (6.5,  8, 2, "ServerHello + Certificate + Finished", "#FF9800"),
        (5.5,  2, 8, "Finished  ← Fin TLS handshake (RTT 2)", "#FF9800"),
        (4.0,  2, 8, "GET /index.html", "#9C27B0"),
        (3.0,  8, 2, "HTTP/2 Response", "#9C27B0"),
    ]

    for y, x1, x2, label, color in msgs:
        ax.annotate("", xy=(x2, y), xytext=(x1, y),
                    arrowprops=dict(arrowstyle="->", color=color, lw=1.8))
        mid_x = (x1 + x2) / 2
        ax.text(mid_x, y + 0.25, label, ha="center", fontsize=7.5, color=color)

    # Accolades RTT
    ax.annotate("", xy=(9.3, 8.8), xytext=(9.3, 10.8),
                arrowprops=dict(arrowstyle="<->", color="#4CAF50", lw=1.5))
    ax.text(9.6, 9.8, "RTT 1\n(TCP)", fontsize=8, color="#4CAF50")

    ax.annotate("", xy=(9.3, 5.5), xytext=(9.3, 7.5),
                arrowprops=dict(arrowstyle="<->", color="#FF9800", lw=1.5))
    ax.text(9.6, 6.5, "RTT 2\n(TLS)", fontsize=8, color="#FF9800")

    ax.text(5, 0.5, "Données disponibles après 2 RTT", ha="center",
            fontsize=9, style="italic", color="gray")

def draw_handshake_quic(ax):
    """Diagramme séquentiel QUIC 1-RTT et 0-RTT"""
    ax.set_xlim(0, 10)
    ax.set_ylim(0, 12)
    ax.set_title("QUIC\n(nouvelle connexion : 1 RTT, reprise : 0-RTT)", fontsize=12, fontweight="bold")
    ax.axis("off")

    ax.axvline(x=2, color="#2196F3", linewidth=2, ymin=0.05, ymax=0.95)
    ax.axvline(x=8, color="#F44336", linewidth=2, ymin=0.05, ymax=0.95)
    ax.text(2, 11.5, "Client", ha="center", fontsize=11, fontweight="bold", color="#2196F3")
    ax.text(8, 11.5, "Serveur", ha="center", fontsize=11, fontweight="bold", color="#F44336")

    msgs_1rtt = [
        (10.0, 2, 8, "Initial (CRYPTO: ClientHello)", "#4CAF50"),
        (9.0,  8, 2, "Initial (CRYPTO: ServerHello) + Handshake + 1-RTT", "#4CAF50"),
        (8.0,  2, 8, "Handshake (CRYPTO: Finished)", "#4CAF50"),
        (7.0,  2, 8, "GET /index.html  ← Données dès RTT 1", "#9C27B0"),
        (6.0,  8, 2, "HTTP/3 Response", "#9C27B0"),
    ]

    for y, x1, x2, label, color in msgs_1rtt:
        ax.annotate("", xy=(x2, y), xytext=(x1, y),
                    arrowprops=dict(arrowstyle="->", color=color, lw=1.8))
        mid_x = (x1 + x2) / 2
        ax.text(mid_x, y + 0.25, label, ha="center", fontsize=7.5, color=color)

    ax.annotate("", xy=(9.3, 9.0), xytext=(9.3, 10.0),
                arrowprops=dict(arrowstyle="<->", color="#4CAF50", lw=1.5))
    ax.text(9.5, 9.5, "RTT 1\n(QUIC+TLS)", fontsize=8, color="#4CAF50")

    # 0-RTT resumption
    ax.text(5, 4.5, "── Reprise de connexion (0-RTT) ──", ha="center",
            fontsize=9, fontweight="bold", color="#FF5722")
    msgs_0rtt = [
        (3.8, 2, 8, "Initial + 0-RTT (données early)", "#FF5722"),
        (2.8, 8, 2, "HTTP/3 Response (données dès l'envoi)", "#FF5722"),
    ]
    for y, x1, x2, label, color in msgs_0rtt:
        ax.annotate("", xy=(x2, y), xytext=(x1, y),
                    arrowprops=dict(arrowstyle="->", color=color, lw=1.8))
        mid_x = (x1 + x2) / 2
        ax.text(mid_x, y + 0.25, label, ha="center", fontsize=7.5, color=color)

    ax.text(5, 0.5, "Données disponibles après 1 RTT (0 RTT en reprise)", ha="center",
            fontsize=9, style="italic", color="gray")

draw_handshake_tcp_tls(axes[0])
draw_handshake_quic(axes[1])
plt.tight_layout()
plt.savefig("handshakes_comparison.png", dpi=120, bbox_inches="tight")
plt.show()
_images/cdc9bd4b35a5da0dc3ae3f1f4e11b4c9aac190e0d6b95b52253c344a1f7371de.png

QUIC : architecture et principes fondamentaux#

QUIC sur UDP#

QUIC (Quick UDP Internet Connections) utilise UDP comme couche de transport. Ce choix peut sembler paradoxal — UDP est non fiable, sans ordre, sans contrôle de congestion — mais c’est précisément ce qui permet à QUIC d’implémenter ses propres mécanismes dans l’espace utilisateur, sans dépendre du noyau du système d’exploitation.

QUIC réimplémente au-dessus d’UDP :

  • La fiabilité et la retransmission des données

  • Le contrôle de flux (par flux et par connexion)

  • Le contrôle de congestion (compatible avec TCP : New Reno, CUBIC, BBR)

  • Le chiffrement intégral (TLS 1.3 est obligatoire, inclus dans QUIC lui-même)

Le fait qu’UDP soit présent partout (tout équipement réseau laisse passer UDP port 443) et que QUIC soit chiffré (les middleboxes ne peuvent pas l’inspecter) règle le problème d’ossification du protocole.

Streams indépendants et multiplexage sans HoL blocking#

La caractéristique centrale de QUIC est son modèle de streams indépendants. Une connexion QUIC peut contenir plusieurs streams (équivalents aux streams HTTP/2), mais chaque stream est livré indépendamment :

  • Les données d’un stream ne bloquent pas les autres streams en cas de perte de paquet

  • La retransmission ne concerne que le stream affecté par la perte

  • Les autres streams continuent d’être livrés à l’application immédiatement

HoL blocking HTTP/2 vs QUIC

HTTP/2 sur TCP : 3 streams, un paquet perdu → les 3 streams sont bloqués jusqu’à retransmission.

HTTP/3 sur QUIC : 3 streams, un paquet perdu → seul le stream concerné attend la retransmission. Les 2 autres streams continuent sans interruption.

Connexion QUIC et Connection ID#

Une connexion QUIC est identifiée par un Connection ID (CID), un identifiant opaque choisi par le client et/ou le serveur, et non par le tuple (IP source, port source, IP destination, port destination) comme TCP.

Cette propriété fondamentale permet la mobilité réseau : si un client passe du Wi-Fi à la 4G, son adresse IP change, mais le Connection ID reste valide. La connexion QUIC est maintenue sans interruption, sans avoir besoin de reestablir TLS. C’est particulièrement précieux pour les applications mobiles (vidéo en streaming, VoIP, navigation web sur smartphone).

Hide code cell source

# Visualisation : streams QUIC indépendants vs TCP sérialisé
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

def draw_streams(ax, title, blocking=True):
    ax.set_xlim(0, 20)
    ax.set_ylim(0, 6)
    ax.set_title(title, fontsize=12, fontweight="bold")
    ax.axis("off")

    colors = ["#2196F3", "#4CAF50", "#FF9800"]
    labels = ["Stream 1 (image A)", "Stream 2 (image B)", "Stream 3 (image C)"]

    # Paquets : liste de (stream, start, end)
    paquets = [
        # S1: 0-2, 3-5 (perte à t=2-3), 5-8
        # S2: 0-2, 3-5, 6-8
        # S3: 1-3, 4-6
    ]

    # Dessin des barres de temps
    ypos = [4.5, 3.0, 1.5]

    if not blocking:
        # QUIC : streams indépendants
        segments = {
            0: [(0, 2), (2.5, 4.5), (4.5, 7)],      # stream 1 : retrans à t=2.5
            1: [(0, 2), (2, 4), (4, 6.5)],             # stream 2 : continu
            2: [(0.5, 2.5), (2.5, 5), (5, 7.5)],       # stream 3 : continu
        }
        perte_stream = 0
        perte_x = 2
    else:
        # TCP : tout bloqué
        segments = {
            0: [(0, 2), (3.5, 5.5), (5.5, 8)],
            1: [(0, 2), (3.5, 5.5), (5.5, 8.5)],
            2: [(0, 2), (3.5, 5.5), (5.5, 9)],
        }
        perte_stream = None
        perte_x = 2

    for i, (y, segs, label, color) in enumerate(zip(ypos, segments.values(), labels, colors)):
        ax.text(0, y + 0.5, label, fontsize=8.5, color=color, fontweight="bold")
        for j, (start, end) in enumerate(segs):
            alpha = 0.4 if (blocking and start >= 3.5 and j == 0) else 0.85
            ax.barh(y, end - start, left=start, height=0.6, color=color, alpha=alpha, edgecolor="white")

    # Zone de blocage TCP
    if blocking:
        ax.axvspan(2, 3.5, alpha=0.1, color="red")
        ax.text(2.75, 5.3, "Perte\npaquet\n→ BLOCAGE", ha="center", fontsize=7.5,
                color="red", fontweight="bold")
        ax.annotate("", xy=(3.5, 5.0), xytext=(2, 5.0),
                    arrowprops=dict(arrowstyle="<->", color="red", lw=1.5))
        ax.text(2.75, 5.1, "", ha="center")
    else:
        ax.axvline(x=2, color="red", linestyle="--", linewidth=1.5, alpha=0.7)
        ax.text(2.1, 5.3, "Perte stream 1\nseul stream 1\nattend", fontsize=7.5,
                color="red")
        ax.barh(ypos[0], 1.5, left=2, height=0.6, color=colors[0], alpha=0.2,
                edgecolor="red", linestyle="--", linewidth=1.5)

    ax.set_xlabel("Temps (unités arbitraires)", fontsize=10)
    ax.axhline(y=0.5, color="gray", linestyle=":", alpha=0.3)

draw_streams(axes[0], "HTTP/2 sur TCP\nUn paquet perdu bloque TOUS les streams", blocking=True)
draw_streams(axes[1], "HTTP/3 sur QUIC\nSeul le stream affecté attend la retransmission", blocking=False)
plt.tight_layout()
plt.show()
_images/98b6211801e499ec579a5b2942d78844d2b14ced9a0b4350f8ae16c4347b2184.png

Handshake QUIC : 1-RTT et 0-RTT#

Le handshake QUIC intègre directement TLS 1.3. Au lieu d’effectuer TCP puis TLS séparément, QUIC combine les deux en une seule négociation :

Première connexion (1-RTT) :

  1. Le client envoie un paquet Initial contenant le ClientHello TLS.

  2. Le serveur répond avec ServerHello, son certificat, et son Finished TLS.

  3. À partir de là, les deux parties peuvent échanger des données chiffrées.

  4. Le client envoie son Finished et les premières données HTTP/3.

La négociation complète ne prend qu”1 RTT (contre 2 RTT pour TCP + TLS 1.3).

Reprise de connexion (0-RTT) : Lors d’une connexion précédente, le serveur transmet un session ticket au client. Lors d’une reconnexion, le client peut envoyer des données applicatives dans le tout premier paquet, avant que le handshake soit terminé. Ces données 0-RTT peuvent être rejouées si le ticket est compromis, donc QUIC les limite aux requêtes idempotentes (GET).

Sécurité des données 0-RTT

Les données 0-RTT sont vulnérables aux attaques par rejeu (replay attacks). Un attaquant qui capture le premier paquet peut le renvoyer au serveur. C’est pourquoi les requêtes 0-RTT doivent être idempotentes (un GET répété ne doit pas avoir d’effet de bord). Les requêtes POST ou d’achat en ligne ne doivent jamais être envoyées en 0-RTT.

Connection ID et mobilité réseau#

Contrairement à TCP, dont la connexion est identifiée par le quintuplet (IP src, port src, IP dst, port dst, protocole), une connexion QUIC est identifiée par son Connection ID (CID), un nombre opaque d’octets inclus dans chaque paquet QUIC.

Hide code cell source

# Visualisation : mobilité réseau avec QUIC Connection ID
fig, ax = plt.subplots(figsize=(12, 5))
ax.set_xlim(0, 14)
ax.set_ylim(0, 5)
ax.axis("off")
ax.set_title("Mobilité réseau : QUIC Connection ID vs TCP", fontsize=13, fontweight="bold")

# Timeline
ax.annotate("", xy=(13.5, 2.5), xytext=(0.5, 2.5),
            arrowprops=dict(arrowstyle="->", color="gray", lw=2))
ax.text(7, 0.2, "Temps", ha="center", fontsize=10, color="gray")

# Evénements sur la timeline
events = [
    (1.5, "Connexion\nWi-Fi\n192.168.1.10"),
    (5.0, "Transition\nWi-Fi → 4G"),
    (8.5, "Connexion\n4G\n10.0.0.55"),
    (12.0, "Retour\nWi-Fi"),
]
for x, label in events:
    ax.axvline(x=x, color="#9E9E9E", linestyle=":", linewidth=1.5, ymin=0.1, ymax=0.9)
    ax.text(x, 4.6, label, ha="center", fontsize=8, color="#424242")

# TCP : connexion cassée
ax.annotate("", xy=(4.7, 3.8), xytext=(1.5, 3.8),
            arrowprops=dict(arrowstyle="-", color="#F44336", lw=3))
ax.annotate("", xy=(8.7, 3.8), xytext=(5.3, 3.8),
            arrowprops=dict(arrowstyle="-", color="#F44336", lw=3))
ax.annotate("", xy=(12.5, 3.8), xytext=(9.0, 3.8),
            arrowprops=dict(arrowstyle="-", color="#F44336", lw=3))
ax.text(0.3, 3.8, "TCP", ha="center", fontsize=10, fontweight="bold", color="#F44336")
ax.text(5.0, 3.5, "✗ Connexion\ncassée", ha="center", fontsize=8, color="#F44336")
ax.text(8.5, 3.5, "✗ Reconnexion\n+2 RTT", ha="center", fontsize=8, color="#F44336")
ax.text(12.0, 3.5, "✗ Cassée", ha="center", fontsize=8, color="#F44336")

# QUIC : connexion maintenue
ax.annotate("", xy=(12.5, 1.5), xytext=(1.5, 1.5),
            arrowprops=dict(arrowstyle="-", color="#4CAF50", lw=4))
ax.text(0.3, 1.5, "QUIC", ha="center", fontsize=10, fontweight="bold", color="#4CAF50")
ax.text(5.0, 1.0, "CID maintenu\nIP change", ha="center", fontsize=8, color="#4CAF50")
ax.text(8.5, 1.0, "CID maintenu\nIP change", ha="center", fontsize=8, color="#4CAF50")
ax.text(7, 2.1, "Connection ID = 0x4a7b... (inchangé)", ha="center",
        fontsize=9, color="#2E7D32", style="italic")

plt.tight_layout()
plt.show()
_images/4ad26a4094bf2f52601f71dc230de1381de794636fe835e9e19d97bdd2200d04.png

HTTP/3 : mapping sur QUIC#

Architecture de HTTP/3#

HTTP/3 (RFC 9114) utilise QUIC comme transport. L’architecture est radicalement différente de HTTP/2 sur TCP :

Couche

HTTP/2

HTTP/3

Application

HTTP/2 frames

HTTP/3 frames

Sécurité

TLS 1.3

TLS 1.3 (dans QUIC)

Transport

TCP

QUIC

Réseau

IP

IP

Liaison

Ethernet/Wi-Fi

Ethernet/Wi-Fi

HTTP/3 conserve la sémantique de HTTP/2 (méthodes, codes de statut, en-têtes, corps) mais redéfinit le format des frames pour les adapter aux streams QUIC et introduit QPACK pour remplacer HPACK.

QPACK vs HPACK : compression des en-têtes#

HPACK (HTTP/2) comprime les en-têtes en utilisant une table d’encodage partagée entre client et serveur. Cette table est mise à jour de manière ordonnée sur la connexion TCP. Or, sur QUIC, les streams sont indépendants et les paquets peuvent arriver dans le désordre. Si le client envoie une mise à jour de la table HPACK sur le stream 1 et que la requête du stream 3 fait référence à une entrée de cette table, le stream 3 doit attendre que le stream 1 soit reçu — introduisant du HoL blocking.

QPACK (RFC 9204) résout ce problème :

  • Il utilise des streams de contrôle dédiés (encoder stream, decoder stream) séparés des streams de données.

  • L’encodeur peut envoyer des mises à jour de table sans bloquer les autres requêtes.

  • QPACK permet l’encodage sans blocage (en n’utilisant que des entrées de table statique ou en répétant les en-têtes sans référence à une entrée récente).

Table statique QPACK

QPACK définit une table statique de 99 entrées prédéfinies (paires (en-tête, valeur) fréquentes comme :status: 200, content-type: text/html, etc.). Cette table est identique côté client et serveur sans échange préalable, permettant une compression immédiate dès la première requête.

Frames HTTP/3#

HTTP/3 définit ses propres types de frames, transmises dans les streams QUIC :

Frame

Description

DATA

Corps de la requête ou de la réponse

HEADERS

En-têtes compressés QPACK

CANCEL_PUSH

Annulation d’un server push

SETTINGS

Paramètres de connexion

PUSH_PROMISE

Annonce de server push

GOAWAY

Arrêt gracieux de la connexion

MAX_PUSH_ID

Limite du push serveur

HTTP/3 n’utilise pas les frames PRIORITY de HTTP/2 ; la priorisation est déléguée à QUIC (RFC 9218 pour les extensions de priorisation).

Adoption et déploiement#

État de l’adoption en 2025-2026#

HTTP/3 a été rapidement adopté par les grands acteurs du web :

  • Google : déploiement sur tous les services (Search, YouTube, Gmail) depuis 2020

  • Cloudflare : support HTTP/3 par défaut pour tous les sites derrière Cloudflare

  • Meta (Facebook/Instagram) : utilise QUIC en interne depuis 2017

  • Apple : support dans Safari depuis iOS 14/macOS 11

Selon les statistiques W3Techs, plus de 30 % des sites web actifs supportent HTTP/3 en 2025.

Alt-Svc et ALPN#

La découverte de HTTP/3 se fait en deux étapes :

Alt-Svc header : Le serveur annonce dans une réponse HTTP/1.1 ou HTTP/2 qu’il supporte HTTP/3 via l’en-tête Alt-Svc :

Alt-Svc: h3=":443"; ma=86400

Cela indique que le protocole h3 (HTTP/3) est disponible sur le port 443 et que cette information est valide 86400 secondes.

ALPN (Application-Layer Protocol Negotiation) : QUIC utilise l’extension TLS ALPN pour négocier le protocole applicatif. Le client propose h3 dans le ClientHello, le serveur confirme.

HTTPS Resource Records (HTTPS RR)

Le DNS propose désormais un enregistrement HTTPS (type 65) qui peut annoncer directement le support d’HTTP/3 sans nécessiter une première connexion HTTP/1.1 ou HTTP/2. Cela permet au client de tenter QUIC/HTTP/3 dès la première connexion, même sans visite précédente du site.

Hide code cell source

# Visualisation : adoption HTTP/3 et performance comparée
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Graphique 1 : Adoption temporelle (données approximatives)
annees = [2019, 2020, 2021, 2022, 2023, 2024, 2025]
adoption_h3 = [2, 5, 12, 20, 25, 29, 33]
adoption_h2 = [45, 55, 62, 68, 72, 76, 79]
adoption_h1 = [53, 40, 26, 12, 3, -5, -12]  # décroissance

ax1 = axes[0]
ax1.fill_between(annees, 0, adoption_h3, alpha=0.7, color="#4CAF50", label="HTTP/3")
ax1.fill_between(annees, adoption_h3, [h2 + h3 for h2, h3 in zip(adoption_h2, adoption_h3)],
                 alpha=0.7, color="#2196F3", label="HTTP/2")
ax1.plot(annees, adoption_h3, "o-", color="#2E7D32", linewidth=2, markersize=5)
ax1.plot(annees, [h2 + h3 for h2, h3 in zip(adoption_h2, adoption_h3)],
         "o-", color="#1565C0", linewidth=2, markersize=5)
ax1.set_xlabel("Année", fontsize=11)
ax1.set_ylabel("% des 1M premiers sites web", fontsize=11)
ax1.set_title("Adoption HTTP/2 et HTTP/3\n(données approximatives)", fontsize=12)
ax1.legend(loc="upper left", fontsize=10)
ax1.set_ylim(0, 120)
ax1.set_xlim(2019, 2025)
ax1.grid(True, alpha=0.3)

# Graphique 2 : Comparaison de performance TTFB
ax2 = axes[1]
protocoles = ["HTTP/1.1\nTCP+TLS1.3", "HTTP/2\nTCP+TLS1.3", "HTTP/3\nQUIC", "HTTP/3\n0-RTT"]
ttfb_faible_perte = [180, 120, 80, 40]
ttfb_forte_perte = [450, 520, 180, 95]   # 2% packet loss

x = np.arange(len(protocoles))
width = 0.35
bars1 = ax2.bar(x - width/2, ttfb_faible_perte, width, label="Perte 0 % (réseau idéal)",
                color=["#4CAF50"]*4, alpha=0.85, edgecolor="white")
bars2 = ax2.bar(x + width/2, ttfb_forte_perte, width, label="Perte 2 % (réseau dégradé)",
                color=["#F44336"]*4, alpha=0.85, edgecolor="white")

ax2.set_xlabel("Protocole", fontsize=11)
ax2.set_ylabel("TTFB (ms) — Time To First Byte", fontsize=11)
ax2.set_title("Performance TTFB comparée\n(simulation, 100 ms RTT de base)", fontsize=12)
ax2.set_xticks(x)
ax2.set_xticklabels(protocoles, fontsize=9)
ax2.legend(fontsize=9)
ax2.grid(True, alpha=0.3, axis="y")

for bar in bars1:
    ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 5,
             f"{bar.get_height()}ms", ha="center", va="bottom", fontsize=8)
for bar in bars2:
    ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 5,
             f"{bar.get_height()}ms", ha="center", va="bottom", fontsize=8)

plt.tight_layout()
plt.show()
_images/895ffe35551fe49bea22e894cc8e4685a61fb0e9f31ce21603a7e845a24184c3.png

Code Python : requêtes HTTP/2 avec httpx et simulation de latence#

Utilisation de httpx pour HTTP/2#

La bibliothèque httpx supporte HTTP/2 (et HTTP/3 via une extension expérimentale). Voici comment effectuer des requêtes réelles avec HTTP/2 :

import httpx
import asyncio
import time

async def comparer_protocoles():
    """Compare les temps de réponse avec différentes configurations httpx."""

    url = "https://www.cloudflare.com/"

    # HTTP/1.1 (sans http2=True)
    t0 = time.perf_counter()
    async with httpx.AsyncClient(http2=False, verify=True) as client:
        r1 = await client.get(url)
    t1 = time.perf_counter()
    print(f"HTTP/{r1.http_version}{r1.status_code}{(t1-t0)*1000:.1f} ms")

    # HTTP/2
    t0 = time.perf_counter()
    async with httpx.AsyncClient(http2=True, verify=True) as client:
        r2 = await client.get(url)
    t1 = time.perf_counter()
    print(f"HTTP/{r2.http_version}{r2.status_code}{(t1-t0)*1000:.1f} ms")

asyncio.run(comparer_protocoles())

Requêtes multiplexées HTTP/2#

import httpx
import asyncio
import time

async def requetes_multiplexees():
    """
    Démontre le multiplexage HTTP/2 : N requêtes en parallèle
    sur une seule connexion TCP.
    """
    urls = [
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
    ]

    # HTTP/2 : toutes les requêtes sur une seule connexion
    async with httpx.AsyncClient(http2=True) as client:
        t0 = time.perf_counter()
        reponses = await asyncio.gather(*[client.get(url) for url in urls])
        duree_h2 = time.perf_counter() - t0

    print(f"HTTP/2 — {len(urls)} requêtes parallèles : {duree_h2:.2f}s")
    # Sur une connexion rapide, ~1s au lieu de 4s car multiplexé

asyncio.run(requetes_multiplexees())

Hide code cell source

# Simulation de latence et comparaison des protocoles
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

def simuler_temps_chargement(n_ressources, rtt_ms, perte_paquets, protocole):
    """
    Simule le temps de chargement d'une page avec N ressources.

    Paramètres :
    - n_ressources : nombre de ressources à télécharger
    - rtt_ms : RTT de base en ms
    - perte_paquets : taux de perte en fraction (0.02 = 2%)
    - protocole : "http11", "http2_tcp", "http3_quic"
    """
    np.random.seed(42)

    if protocole == "http11":
        # HTTP/1.1 : jusqu'à 6 connexions parallèles, séquentiel sinon
        # 2 RTT pour TCP+TLS par connexion, puis RTT par requête
        n_connexions = 6
        batches = np.ceil(n_ressources / n_connexions)
        # Retransmission : chaque paquet perdu ajoute ~1 RTT
        retrans_overhead = perte_paquets * rtt_ms * n_ressources * 2
        temps = (2 * rtt_ms) + batches * rtt_ms + retrans_overhead

    elif protocole == "http2_tcp":
        # HTTP/2 : 1 connexion TCP+TLS, toutes les ressources multiplexées
        # Mais perte = HoL blocking = tous les streams attendent
        n_bloques_par_perte = perte_paquets * 50 * n_ressources  # ~50 paquets par ressource
        overhead_hol = n_bloques_par_perte * rtt_ms * 0.3
        temps = (2 * rtt_ms) + rtt_ms + overhead_hol

    elif protocole == "http3_quic":
        # QUIC : 1 RTT handshake, streams indépendants
        # Perte ne bloque qu'un stream, les autres continuent
        overhead_quic = perte_paquets * rtt_ms * n_ressources * 0.1
        temps = rtt_ms + rtt_ms + overhead_quic

    return temps

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Graphique 1 : temps vs taux de perte de paquets
rtt = 80  # ms
n_res = 30
pertes = np.linspace(0, 0.05, 50)
protocoles = {
    "HTTP/1.1": ("http11", "#FF9800"),
    "HTTP/2 (TCP)": ("http2_tcp", "#2196F3"),
    "HTTP/3 (QUIC)": ("http3_quic", "#4CAF50"),
}

ax1 = axes[0]
for label, (proto, color) in protocoles.items():
    temps = [simuler_temps_chargement(n_res, rtt, p, proto) for p in pertes]
    ax1.plot(pertes * 100, temps, "-", label=label, color=color, linewidth=2.5)

ax1.set_xlabel("Taux de perte de paquets (%)", fontsize=11)
ax1.set_ylabel("Temps de chargement (ms)", fontsize=11)
ax1.set_title(f"Impact des pertes paquets sur le temps de chargement\n({n_res} ressources, RTT={rtt}ms)", fontsize=11)
ax1.legend(fontsize=10)
ax1.grid(True, alpha=0.3)
ax1.set_xlim(0, 5)

# Graphique 2 : temps vs RTT
perte = 0.01  # 1%
rtts = np.linspace(10, 300, 50)

ax2 = axes[1]
for label, (proto, color) in protocoles.items():
    temps = [simuler_temps_chargement(n_res, r, perte, proto) for r in rtts]
    ax2.plot(rtts, temps, "-", label=label, color=color, linewidth=2.5)

ax2.set_xlabel("RTT (ms)", fontsize=11)
ax2.set_ylabel("Temps de chargement (ms)", fontsize=11)
ax2.set_title(f"Impact du RTT sur le temps de chargement\n({n_res} ressources, perte={perte*100:.0f}%)", fontsize=11)
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)
ax2.set_xlim(10, 300)

plt.tight_layout()
plt.show()
_images/3f24e0619dcf60ba171fe90bfd336a646c92ff68b6e3302b118d8678773d58ff.png

Hide code cell source

# Analyse QPACK vs HPACK : visualisation de la compression des en-têtes
fig, ax = plt.subplots(figsize=(12, 6))

# En-têtes typiques d'une requête HTTP
entetes = {
    ":method: GET": 10,
    ":path: /api/users": 16,
    ":scheme: https": 12,
    ":authority: api.example.com": 26,
    "accept: application/json": 24,
    "accept-encoding: gzip, br": 25,
    "user-agent: Mozilla/5.0 ...": 80,
    "authorization: Bearer eyJ...": 200,
    "content-type: application/json": 31,
    "cache-control: no-cache": 22,
}

noms = list(entetes.keys())
tailles_raw = list(entetes.values())
n = len(noms)
x = np.arange(n)

# Simulation compression
tailles_hpack = [max(1, int(t * 0.15)) if t > 15 else t for t in tailles_raw]   # HPACK 1re req = 85% compression en-têtes communs
tailles_hpack_rep = [max(1, int(t * 0.05)) for t in tailles_raw]                 # HPACK requête répétée
tailles_qpack = [max(1, int(t * 0.15)) if t > 15 else t for t in tailles_raw]   # QPACK similaire
tailles_qpack_rep = [max(1, int(t * 0.05)) for t in tailles_raw]                 # QPACK sans blocage

width = 0.2
ax.bar(x - width*1.5, tailles_raw, width, label="Raw (non compressé)", color="#F44336", alpha=0.8)
ax.bar(x - width*0.5, tailles_hpack, width, label="HPACK 1ère req.", color="#FF9800", alpha=0.8)
ax.bar(x + width*0.5, tailles_hpack_rep, width, label="HPACK req. répétée", color="#2196F3", alpha=0.8)
ax.bar(x + width*1.5, tailles_qpack_rep, width, label="QPACK req. répétée", color="#4CAF50", alpha=0.8)

ax.set_xticks(x)
ax.set_xticklabels([n[:20] + "…" if len(n) > 20 else n for n in noms],
                   rotation=45, ha="right", fontsize=8)
ax.set_ylabel("Taille encodée (octets)", fontsize=11)
ax.set_title("Compression des en-têtes HTTP : Raw vs HPACK vs QPACK", fontsize=12)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3, axis="y")

# Totaux
total_raw = sum(tailles_raw)
total_hpack = sum(tailles_hpack_rep)
total_qpack = sum(tailles_qpack_rep)
ax.text(0.02, 0.97,
        f"Total raw: {total_raw}o | HPACK répété: {total_hpack}o ({100*total_hpack//total_raw}%) | QPACK répété: {total_qpack}o ({100*total_qpack//total_raw}%)",
        transform=ax.transAxes, fontsize=9, va="top",
        bbox=dict(boxstyle="round", facecolor="lightyellow", alpha=0.8))

plt.tight_layout()
plt.show()
_images/d5272d5c6a74ce86ae22f0ce04d056b382628727a16df0a1e45167fce9bb406a.png

Résumé et perspectives#

HTTP/3 et QUIC constituent une avancée majeure dans l’architecture des protocoles web. En abandonnant TCP, ils résolvent des problèmes fondamentaux que HTTP/2 ne pouvait pas adresser : le HoL blocking au niveau transport, la lenteur des handshakes et la rigidité des connexions. Les bénéfices sont particulièrement visibles dans des conditions réseau dégradées (réseaux mobiles, Wi-Fi instable, longues distances).

Hide code cell source

# Tableau récapitulatif des caractéristiques
fig, ax = plt.subplots(figsize=(13, 5))
ax.axis("off")

colonnes = ["Caractéristique", "HTTP/1.1\nTCP+TLS", "HTTP/2\nTCP+TLS", "HTTP/3\nQUIC"]
lignes = [
    ["Transport", "TCP", "TCP", "QUIC (UDP)"],
    ["Chiffrement", "TLS (optionnel)", "TLS 1.2+", "TLS 1.3 (obligatoire)"],
    ["Handshake initial", "2 RTT", "2 RTT", "1 RTT"],
    ["Reprise connexion", "1 RTT (TLS session)", "1 RTT (TLS session)", "0-RTT"],
    ["Multiplexage", "Non (6 conn.)", "Oui (1 conn.)", "Oui (streams QUIC)"],
    ["HoL blocking", "Au niveau HTTP", "Au niveau TCP", "Aucun"],
    ["Mobilité réseau", "Non", "Non", "Oui (Connection ID)"],
    ["Compression en-têtes", "Non", "HPACK", "QPACK"],
    ["Server push", "Non", "Oui", "Limité (HTTP/3)"],
]

couleurs_header = ["#37474F"] * 4
couleurs_lignes = []
for i, ligne in enumerate(lignes):
    if i % 2 == 0:
        couleurs_lignes.append(["#ECEFF1", "#E8F5E9", "#E3F2FD", "#E8F5E9"])
    else:
        couleurs_lignes.append(["#F5F5F5", "#F1F8E9", "#E1F5FE", "#F1F8E9"])

table = ax.table(
    cellText=lignes,
    colLabels=colonnes,
    cellLoc="center",
    loc="center",
    cellColours=couleurs_lignes,
)
table.auto_set_font_size(False)
table.set_fontsize(9)
table.scale(1, 1.8)

for j in range(4):
    table[0, j].set_facecolor("#37474F")
    table[0, j].set_text_props(color="white", fontweight="bold")

ax.set_title("Comparaison HTTP/1.1, HTTP/2 et HTTP/3", fontsize=13, fontweight="bold", pad=20)
plt.tight_layout()
plt.show()
_images/9d954d4dd07abce0b08df66521a342aafc58a4ad85a8266e79be0edd58486d95.png

Les points clés à retenir :

  • QUIC résout le HoL blocking TCP grâce à ses streams indépendants

  • 1 RTT (et 0-RTT en reprise) au lieu de 2 RTT pour établir une connexion sécurisée

  • Le Connection ID permet la mobilité réseau transparente

  • QPACK adapte HPACK à l’environnement sans ordre de QUIC

  • L’adoption est rapide : +30 % des sites web en 2025, tous les grands CDN supportés

  • Les gains sont surtout visibles sur les réseaux dégradés et les accès mobiles