Python and HTTP/3
Feel the difference in performance


EuroPython 2026, 15.07 KrakΓ³w

Daniel Vahla


Slides avaiable at ep2026.vahla.fi

About Me

  • πŸ‘¨β€πŸ’» Name: Daniel Vahla
  • 🏒 Roles:
    • Senior Software Engineer at ICEYE
    • Meetup Organizer at Helsinki Python and PyData Helsinki
  • 🐍 Python Experience: 8 years
  • πŸ’‘ Interests: Cloud, Serverless, Python
  • ⚽ Hobbies: Football

What you'll take away


  • Witness an app suffering from transport-layer head-of-line blocking
  • Understand why HTTP/2 doesn't fix the problem for real-time apps
  • Learn about QUIC, HTTP/3, and WebTransport
  • See a Starlette + aioquic server handling WebTransport datagrams
  • Know how to use Linux tc to inject controlled network chaos
  • Play multiplayer Snake

The invisible enemy

Users complain: "It feels laggy on my phone"


You check your server metrics:

  • CPU: 3%
  • Memory: normal
  • Request latency: fine
  • Business logic: optimised

Everything looks fine.

Meanwhile, your user is:

  • On conference WiFi
  • Switching between access points
  • Sitting on a train
  • Using 4G in a busy area

Their network isn't fine.


The problem is not your business logic code. It's the transport protocol underneath it.

Part 1

HTTP/1.1 and HTTP/2

TCP: guaranteed order, at a cost

HTTP/1.1 and HTTP/2 run over TCP, a protocol that guarantees every byte arrives in order.


sequenceDiagram
    participant C as Client
    participant N as Network
    participant S as Server
    C->>N: TCP segment 1
    C->>N: TCP segment 2
    C->>N: TCP segment 3
    Note over N,S: segment 2 lost!
    N->>S: segment 1 βœ“
    Note over C,S: TCP must deliver in order β€” segment 3 held back
    N-->>C: ACK timeout β†’ retransmit segment 2
    C->>N: segment 2 (retransmit)
    N->>S: segment 2 βœ“
    N->>S: segment 3 βœ“  (delayed by full RTT)

This is Head-of-Line (HOL) Blocking: one lost packet stalls every frame behind it.

Why HOL blocking is invisible to developers

Where you develop

  • Localhost β†’ 0% packet loss
  • Office LAN β†’ < 0.01% loss
  • Home broadband β†’ < 0.1% loss
"Works on my machine" βœ“
"Lags at the conference" βœ—

Where your users are

  • Conference WiFi β†’ 1–3% loss
  • 4G in a crowd β†’ burst drops
  • Train / cafΓ© β†’ variable loss
  • Switching APs β†’ brief blackout

We can reproduce it with Linux tc (traffic control):

tc qdisc add dev <interface> root netem loss <pct>% delay <ms>ms
#                ^^^^^^^^^^^            ^^^^^^^^^       ^^^^^^^^
#                network interface      packet loss     added latency

HTTP/2: multiplexing, but still TCP

HTTP/2 runs multiple logical streams over one TCP connection.

What multiplexing gives you

  • Parallel requests in one connection
  • No per-request queue
  • Great for loading many assets

What it doesn't change

sequenceDiagram
    participant C as Client
    participant T as TCP layer
    participant S as Server
    Note over C,S: HTTP/2 multiplexes streams, but TCP sees one byte stream
    C->>T: [Stream 1 data][Stream 2 data][Stream 3 data]
    Note over T,S: TCP segment dropped!
    T--xS: βœ— lost
    Note over T,S: All HTTP/2 streams blocked β€” TCP must retransmit
    T->>S: retransmit βœ“ β€” all streams unblock together

HTTP/2 doesn't help here. Multiplexing reduces connection overhead for assets, but the underlying TCP HOL blocking is unchanged. Each player in this demo connects from their own device and gets their own TCP connection regardless. The freeze behaviour will be identical to Round 1.

Demo 1/2

Rounds 1 and 2: Feel the blocking

🐍 Round 1: HTTP/1.1 + WebSocket

Open on your phone:

http://pydemo.vahla.fi:8080/http1

What to expect (clean baseline):

  • Input to on-screen response feels instant
  • Each player's snake moves independently
  • No freezes, no teleporting

Injecting chaos

# Applied on the server's outbound interface right now:
tc qdisc add dev <interface> root netem \
  loss 2%          \  # 2% random packet loss
  delay 50ms 5ms      # 50ms base latency + 5ms jitter

Keep playing, notice:

  • Snake freezes mid-move
  • Input commands queue up invisibly
  • After freeze: snake "teleports" forward
  • Other players may freeze at different moments

2% packet loss (typical conference WiFi).

🐍 Round 2: HTTP/2 + WebSocket

Chaos still active. New URL:

https://pydemo.vahla.fi:8443/http2

Watch for:

  • Does it feel any different from Round 1?
  • Are freezes still per-player and independent?

Each player has their own TCP connection.
HTTP/2 multiplexing doesn't change that.
The HOL blocking is the same.

Demo 1/2: what we saw

HTTP/1.1

  • Freezes are per-player (each has own TCP connection)
  • Input queues up, then bursts
  • Recovery is independent

HTTP/2

  • Looks identical to HTTP/1.1 in this demo
  • Each player still has their own TCP connection
  • Multiplexing helps load assets, not real-time streams
  • HOL blocking: unchanged

The common cause

Both run over TCP.
Both suffer from head-of-line blocking.
HTTP/2 was a step forward for page loading, not for real-time.

Under 2% packet loss:

p50 p99
HTTP/1.1 ~45 ms ~520 ms
HTTP/2 ~45 ms ~520 ms

p99 is where users feel the lag.

Part 2

QUIC, HTTP/3, and WebTransport

QUIC: TCP's replacement

HTTP/3 runs over QUIC, a new transport built on UDP, designed for exactly these problems.


sequenceDiagram
    participant C as Client
    participant N as Network
    participant S as Server
    C->>N: QUIC stream 1, packet 1
    C->>N: QUIC stream 2, packet 1
    C->>N: QUIC stream 1, packet 2
    Note over N,S: stream 2, packet 1 lost!
    N->>S: stream 1, packet 1 βœ“
    N->>S: stream 1, packet 2 βœ“
    Note over C,S: Stream 1 keeps flowing β€” QUIC streams are independent
    C->>N: stream 2, packet 1 (retransmit)
    N->>S: stream 2, packet 1 βœ“

Packet loss on one stream does not block any other stream.

QUIC: three more properties

Connection IDs (not IP:port)

Connection identified by an opaque ID.

Phone: IP changes (WiFi β†’ 4G)
QUIC:  same connection ID β†’ migrate
TCP:   different IP β†’ reconnect

Zero reconnection logic in your app.

0-RTT resumption

Returning client sends data immediately.
No handshake round-trip.

Built-in TLS 1.3

TCP + TLS 1.3:            QUIC:

SYN  β†’                    Initial β†’
← SYN-ACK                ← Handshake
ACK + ClientHello β†’       0-RTT data β†’
← ServerHello+Finished
HTTP data β†’
  ↑ 2 RTT                 ↑ 1 RTT (0 for repeat)

WebTransport: more than a WebSocket upgrade

WebSocket over HTTP/3

  • Still a single ordered stream
  • QUIC supports many independent streams, but WebSocket only ever opens one
  • One dropped packet = that stream stalls
  • HOL blocking moves from the TCP layer to the QUIC stream layer

WebTransport

  • Multiple independent QUIC streams
  • Unreliable datagrams: fire-and-forget
  • Stale position update? Drop it.
    Next one arrives in ~50 ms.
  • Each player input is one datagram

For real-time apps: old position data is worthless. An unreliable datagram that might not arrive is better than a reliable message that arrives 400 ms late.

Demo 2/2

Round 3: HTTP/3 + WebTransport

🐍 Round 3: HTTP/3 + WebTransport

Same chaos is still running. New URL:

https://pydemo.vahla.fi:8443/http3

Watch for:

  • Each snake moves on its own timeline, no collective freeze
  • Dropped datagram β†’ next one arrives in ~50 ms, no queuing
  • Identical chaos to Rounds 1 and 2; only the protocol changed

Demo 2/2: what we saw

HTTP/3 + WebTransport

  • Freezes are per-player (independent QUIC streams)
  • Stale datagrams silently dropped by sequence number
  • No collective stall moments
  • Smooth gameplay under identical network chaos

Why it worked

Each player input is one unreliable datagram.
A lost datagram is ignored. The next one arrives in ~50 ms.

HOL blocking Packet loss effect
HTTP/1.1 Per-connection Per-player stall
HTTP/2 Per-connection Per-player stall (same)
HTTP/3 None Datagram dropped; next tick (~50 ms) recovers

Part 3

Implementation

Architecture

flowchart LR
    B1["Browser\nHTTP/1.1"] -->|"TCP :8080\nWebSocket"| H["Hypercorn\n:8080 / :8443"]
    B2["Browser\nHTTP/2"] -->|"TLS+TCP :8443\nWebSocket"| H
    H --> APP["Starlette\nApp"]
    B3["Browser\nHTTP/3"] -->|"QUIC+UDP :8444\nWebTransport datagrams"| AIQ["aioquic\n:8444"]
    AIQ --> APP
    APP --> GE["Game\nEngine"]

One codebase, three transports, three separate processes. Hypercorn handles HTTP/1.1 and HTTP/2 β€” it can serve HTTP/3 too, but the ASGI spec has no interface for WebTransport datagrams (only ordered byte streams), so it can't expose them to application code. aioquic drives port 8444 directly, sharing the same game logic via Python module imports.

Code: WebSocket handler (HTTP/1.1 and HTTP/2)

# Starlette WebSocket β€” same code serves both HTTP/1.1 and HTTP/2
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    room = await room_manager.assign_room()
    player_id = generate_player_id()
    room.game_state.spawn_player(player_id)
    room.websocket_connections[player_id] = websocket

    # Room._game_loop() pushes serialized state to every ws at TICK_RATE.
    # This coroutine only needs to receive direction inputs.
    try:
        while True:
            data = await websocket.receive_json()
            if "direction" in data:
                direction = Direction[data["direction"].upper()]
                room.game_state.set_direction(player_id, direction)
    except WebSocketDisconnect:
        pass
    finally:
        room.game_state.remove_player(player_id)

Code: WebTransport datagrams (HTTP/3)

Input messages are 5 bytes: no JSON, no ordering overhead.

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  seq  (4 bytes)  β”‚ dir (1B) β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  uint32 big-endian    0–3
# aioquic datagram handler
async def handle_datagram(self, data: bytes):
    seq, direction_val = struct.unpack(
        "!IB", data[:5]
    )
    player = self.players.get(self.session_id)
    if player is None:
        return

    # Discard stale inputs (out-of-order UDP)
    if seq <= player.last_input_seq:
        return

    player.last_input_seq = seq
    player.pending_direction = Direction(direction_val)
// Acquire writer once after transport.ready
const writer = transport.datagrams.writable.getWriter();

// On each input event:
const buf = new ArrayBuffer(5);
const view = new DataView(buf);
view.setUint32(0, inputSeq++, false);  // big-endian
view.setUint8(4, direction);            // 0=UP 1=DOWN…

await writer.write(new Uint8Array(buf));

Why unreliable datagrams?

A direction input from 300 ms ago is useless; the player has already pressed something new. Drop it and use the latest.

WebTransport: datagrams vs streams

WebTransport has exactly two transport primitives β€” there is no middle ground:

Unreliable datagram

  • No delivery guarantee
  • No order guarantee
  • Lost packet = gone, no retransmit
  • No HOL blocking

Use for: any data with a freshness window. A missed delivery is fine as long as the next one corrects it.

Reliable ordered stream

  • Guaranteed delivery
  • Guaranteed order
  • Retransmitted until ACKed
  • HOL blocking within the stream

Use for: events where a missed delivery corrupts client state.

Reliable datagrams are not a thing. QUIC datagrams are always unreliable by definition. If you need guaranteed delivery, open a stream.

The Python HTTP/3 ecosystem (May 2026)

Server-side

Library Role
uvicorn ASGI, HTTP/1.1 + WS
hypercorn ASGI, H1 + H2 + H3
aioquic QUIC/H3 primitives
starlette ASGI framework

The gap: ASGI has no standard for WebTransport datagrams yet, so handling them requires direct aioquic integration outside the ASGI layer.

Client-side

Library H1 H2 H3
niquests βœ“ βœ“ βœ“
httpx βœ“ βœ“ β€”
aiohttp βœ“ β€” β€”

niquests is useful for probing and benchmarking from Python scripts, not a browser replacement.

Key takeaways


  1. HOL blocking is invisible on localhost: use tc netem to see real behaviour
  2. HTTP/2 doesn't fix HOL blocking: it moves it from browser to the TCP layer
  3. QUIC gives you independent streams: packet loss is isolated, not global
  4. Connection migration is free: QUIC handles it, zero application code
  5. Prefer WebTransport to WebSocket for real-time: unreliable datagrams are a feature
  6. Python HTTP/3 is usable today: aioquic + Starlette + Hypercorn

Thank you


Python and HTTP/3: feel the difference
Daniel Vahla Β· EuroPython 2026, KrakΓ³w


blog.vahla.fi


Questions?

Appendix

tc netem reference

# Add 2% random loss + 20ms base latency + 5ms jitter
tc qdisc add dev ens5 root netem loss 2% delay 20ms 5ms

# Add loss correlation (real networks: loss tends to cluster)
tc qdisc add dev ens5 root netem \
  loss 2% 25%    \   # 2% loss, 25% correlation between consecutive packets
  delay 20ms 5ms \   # base Β± jitter
  corrupt 0.1%       # 0.1% random bit corruption

# Show active rules
tc qdisc show dev ens5

# Remove all chaos (restore normal)
tc qdisc del dev ens5 root

All audience participants experience the same chaos because it's applied at the server's outbound interface, not per-client.

Bonus: 45 min version

A brief history of WebTransport

Year Event
2019 First WebTransport draft (W3C + IETF)
2020 Early Chrome experiment with quic-transport
2021 Renamed to WebTransport; datagrams and streams defined
2022 Chrome ships WebTransport (v97) behind flag, then stable
2023 Firefox ships WebTransport (v114)
2024 WebTransport Level 1 reaches W3C Candidate Recommendation
2025 Safari ships WebTransport (v18.2, December 2024)
2026 All major browsers support WebTransport + QUIC datagrams

The slow rollout explains why Python server-side tooling is still catching up: browser support only became universal in 2025.

Architecting for HTTP/3: datagrams vs streams

Use unreliable datagrams when:

  • Data has a freshness window (positions, sensors)
  • Latest value supersedes older ones
  • Loss is preferable to delay
  • You add sequence numbers to detect staleness
# Datagram: 5 bytes, fire and forget
seq, dir = struct.unpack("!IB", data)
if seq > player.last_seq:
    player.last_seq = seq
    player.direction = dir

Use reliable streams when:

  • Events must all arrive (chat, scoring, joins)
  • Order matters for correctness
  • No natural "freshness" concept
# Reliable stream: JSON, ordered
stream = await session.open_stream()
await stream.send(json.dumps({
    "type": "player_joined",
    "id": player_id,
}).encode())

This demo uses both: datagrams for game state (20 positions/sec server→client) and direction inputs (client→server) — a stale position or input is irrelevant, so dropping it is fine. Reliable streams for respawn requests — a missed respawn means the player stays dead indefinitely.

Load Mermaid β€” renders when built with: marp --html slides.md

Speaker notes: Get everyone on the page. Give 30s of clean gameplay. The audience needs to feel what "normal" looks like before chaos is applied. Narrate: "Notice how responsive it is right now."