Tue, Feb 3, 2026 Latest

Transport protocols

Analysis of transport protocol usage (QUIC vs TCP) in Ethereum mainnet libp2p connections.

This notebook examines the distribution of QUIC and TCP transport protocols across the network, with breakdown by consensus client implementation. Data is sourced from the EthPandaOps Xatu sentries observing libp2p connection events over a 7-day rolling window.

Overall transport protocol distribution

Breakdown of all libp2p connections by transport protocol. UDP indicates QUIC transport, TCP indicates traditional TCP connections.

Show code
display_sql("transport_overall", target_date)
View query
SELECT
    remote_transport_protocol as transport,
    count(*) as connections,
    round(count(*) * 100.0 / sum(count(*)) OVER (), 2) as percentage
FROM default.libp2p_connected
WHERE meta_network_name = 'mainnet'
  AND event_date_time >= '2026-02-03'::date - INTERVAL 6 DAY AND event_date_time < '2026-02-03'::date + INTERVAL 1 DAY
GROUP BY remote_transport_protocol
ORDER BY connections DESC
Show code
df_overall = load_parquet("transport_overall", target_date)

df_overall["label"] = df_overall.apply(
    lambda r: f"{'QUIC' if r['transport'] == 'udp' else 'TCP'} ({r['percentage']:.1f}%)",
    axis=1
)

fig = px.pie(
    df_overall,
    values="connections",
    names="label",
    color="transport",
    color_discrete_map={"tcp": "#3b82f6", "udp": "#22c55e"},
    hole=0.4,
)
fig.update_layout(
    title="Transport protocol distribution (7-day rolling)",
    height=400,
    showlegend=True,
    legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5),
)
fig.show()

Daily breakdown showing TCP vs QUIC connection counts over time.

Show code
display_sql("transport_daily", target_date)
View query
SELECT
    toDate(event_date_time) as date,
    remote_transport_protocol as transport,
    count(*) as connections,
    uniqExact(remote_peer_id_unique_key) as unique_peers
FROM default.libp2p_connected
WHERE meta_network_name = 'mainnet'
  AND event_date_time >= '2026-02-03'::date - INTERVAL 6 DAY AND event_date_time < '2026-02-03'::date + INTERVAL 1 DAY
GROUP BY date, transport
ORDER BY date, transport
Show code
df_daily = load_parquet("transport_daily", target_date)

df_daily["transport_label"] = df_daily["transport"].map({"tcp": "TCP", "udp": "QUIC"})

fig = px.bar(
    df_daily,
    x="date",
    y="connections",
    color="transport_label",
    color_discrete_map={"TCP": "#3b82f6", "QUIC": "#22c55e"},
    barmode="stack",
    labels={"date": "Date", "connections": "Connections", "transport_label": "Transport"},
)
fig.update_layout(
    title="Daily connections by transport protocol",
    height=400,
    legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
fig.show()

Unique peers by transport protocol

Unique peer analysis showing how many distinct peers connected via each transport protocol. Note that some peers connect via both protocols.

Show code
display_sql("transport_peers", target_date)
View query
WITH peer_protocols AS (
    SELECT
        remote_peer_id_unique_key,
        groupUniqArray(remote_transport_protocol) as protocols
    FROM default.libp2p_connected
    WHERE meta_network_name = 'mainnet'
      AND event_date_time >= '2026-02-03'::date - INTERVAL 6 DAY AND event_date_time < '2026-02-03'::date + INTERVAL 1 DAY
    GROUP BY remote_peer_id_unique_key
)
SELECT
    countIf(has(protocols, 'tcp') AND NOT has(protocols, 'udp')) as tcp_only,
    countIf(has(protocols, 'udp') AND NOT has(protocols, 'tcp')) as quic_only,
    countIf(has(protocols, 'tcp') AND has(protocols, 'udp')) as both,
    count(*) as total
FROM peer_protocols
Show code
df_peer_breakdown = load_parquet("transport_peers", target_date)

peer_data = [
    {"category": "TCP only", "peers": int(df_peer_breakdown["tcp_only"].iloc[0])},
    {"category": "QUIC only", "peers": int(df_peer_breakdown["quic_only"].iloc[0])},
    {"category": "Both", "peers": int(df_peer_breakdown["both"].iloc[0])},
]
df_peers = pd.DataFrame(peer_data)
df_peers["percentage"] = (df_peers["peers"] / df_peers["peers"].sum() * 100).round(1)

fig = px.bar(
    df_peers,
    x="category",
    y="peers",
    color="category",
    color_discrete_map={"TCP only": "#3b82f6", "QUIC only": "#22c55e", "Both": "#a855f7"},
    text=df_peers.apply(lambda r: f"{r['peers']:,} ({r['percentage']}%)", axis=1),
)
fig.update_traces(textposition="outside")
fig.update_layout(
    title="Unique peers by transport capability (7-day rolling)",
    height=400,
    showlegend=False,
    xaxis_title="",
    yaxis_title="Unique peers",
)
fig.show()

Transport protocol by client implementation

QUIC adoption varies significantly by consensus client. This table shows the breakdown of unique peers by their transport protocol capability for each known client implementation.

Show code
display_sql("transport_by_client", target_date)
View query
WITH peer_protocols AS (
    SELECT
        remote_agent_implementation as client,
        remote_peer_id_unique_key,
        groupUniqArray(remote_transport_protocol) as protocols
    FROM default.libp2p_connected
    WHERE meta_network_name = 'mainnet'
      AND event_date_time >= '2026-02-03'::date - INTERVAL 6 DAY AND event_date_time < '2026-02-03'::date + INTERVAL 1 DAY
      AND remote_agent_implementation NOT IN ('', 'unknown')
    GROUP BY client, remote_peer_id_unique_key
)
SELECT
    client,
    countIf(has(protocols, 'tcp') AND NOT has(protocols, 'udp')) as tcp_only,
    countIf(has(protocols, 'udp') AND NOT has(protocols, 'tcp')) as quic_only,
    countIf(has(protocols, 'tcp') AND has(protocols, 'udp')) as both,
    count(*) as total,
    round(countIf(has(protocols, 'udp')) * 100.0 / count(*), 1) as quic_capable_pct
FROM peer_protocols
GROUP BY client
HAVING total > 30
ORDER BY total DESC
Show code
df_clients = load_parquet("transport_by_client", target_date)

df_clients
client tcp_only quic_only both total quic_capable_pct
0 lighthouse 2702 598 504 3804 29.0
1 prysm 1626 631 210 2467 34.1
2 nimbus 1669 0 0 1669 0.0
3 erigon 1369 0 0 1369 0.0
4 teku 766 0 0 766 0.0
5 lodestar 221 0 1 222 0.5
6 rust-libp2p 36 31 7 74 51.4
7 grandine 24 8 19 51 52.9
8 xatu 33 0 0 33 0.0
Show code
df_clients_long = df_clients.melt(
    id_vars=["client", "total"],
    value_vars=["tcp_only", "quic_only", "both"],
    var_name="protocol_type",
    value_name="peers",
)
df_clients_long["protocol_label"] = df_clients_long["protocol_type"].map({
    "tcp_only": "TCP only",
    "quic_only": "QUIC only",
    "both": "Both",
})

client_order = df_clients.sort_values("total", ascending=True)["client"].tolist()

fig = px.bar(
    df_clients_long,
    x="peers",
    y="client",
    color="protocol_label",
    color_discrete_map={"TCP only": "#3b82f6", "QUIC only": "#22c55e", "Both": "#a855f7"},
    orientation="h",
    category_orders={"client": client_order},
    labels={"peers": "Unique peers", "client": "Client", "protocol_label": "Transport"},
)
fig.update_layout(
    title="Transport protocol support by client (7-day rolling)",
    height=500,
    legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
    barmode="stack",
)
fig.show()

QUIC capability by client

Percentage of peers supporting QUIC (either exclusively or alongside TCP) for each client implementation.

Show code
df_quic_pct = df_clients.sort_values("quic_capable_pct", ascending=True).copy()

fig = px.bar(
    df_quic_pct,
    x="quic_capable_pct",
    y="client",
    orientation="h",
    text=df_quic_pct["quic_capable_pct"].apply(lambda x: f"{x:.1f}%"),
    color="quic_capable_pct",
    color_continuous_scale="Greens",
)
fig.update_traces(textposition="outside")
fig.update_layout(
    title="QUIC-capable peers by client (7-day rolling)",
    height=500,
    showlegend=False,
    xaxis_title="% of peers supporting QUIC",
    yaxis_title="",
    coloraxis_showscale=False,
)
fig.update_xaxes(range=[0, 100])
fig.show()

Connection patterns by protocol

Comparison of connection frequency: how many connections each unique peer makes on average, broken down by transport protocol.

Show code
display_sql("transport_connection_patterns", target_date)
View query
SELECT
    remote_transport_protocol as transport,
    count(*) as total_connections,
    uniqExact(remote_peer_id_unique_key) as unique_peers,
    round(count(*) / uniqExact(remote_peer_id_unique_key), 1) as connections_per_peer
FROM default.libp2p_connected
WHERE meta_network_name = 'mainnet'
  AND event_date_time >= '2026-02-03'::date - INTERVAL 6 DAY AND event_date_time < '2026-02-03'::date + INTERVAL 1 DAY
GROUP BY transport
ORDER BY transport
Show code
df_conn_patterns = load_parquet("transport_connection_patterns", target_date)

df_conn_patterns["transport_label"] = df_conn_patterns["transport"].map({"tcp": "TCP", "udp": "QUIC"})

fig = make_subplots(
    rows=1, cols=2,
    subplot_titles=("Unique peers", "Connections per peer"),
)

colors = {"TCP": "#3b82f6", "QUIC": "#22c55e"}

fig.add_trace(
    go.Bar(
        x=df_conn_patterns["transport_label"],
        y=df_conn_patterns["unique_peers"],
        marker_color=[colors[t] for t in df_conn_patterns["transport_label"]],
        text=df_conn_patterns["unique_peers"].apply(lambda x: f"{x:,}"),
        textposition="outside",
        showlegend=False,
    ),
    row=1, col=1,
)

fig.add_trace(
    go.Bar(
        x=df_conn_patterns["transport_label"],
        y=df_conn_patterns["connections_per_peer"],
        marker_color=[colors[t] for t in df_conn_patterns["transport_label"]],
        text=df_conn_patterns["connections_per_peer"].apply(lambda x: f"{x:.1f}"),
        textposition="outside",
        showlegend=False,
    ),
    row=1, col=2,
)

fig.update_layout(
    title="Connection patterns by transport (7-day rolling)",
    height=400,
)
fig.show()

Daily unique peers trend

Daily count of unique peers connecting via each transport protocol.

Show code
df_daily_pivot = df_daily.pivot(index="date", columns="transport_label", values="unique_peers").reset_index()
df_daily_pivot["QUIC %"] = (df_daily_pivot["QUIC"] / (df_daily_pivot["TCP"] + df_daily_pivot["QUIC"]) * 100).round(1)

fig = make_subplots(
    rows=2, cols=1,
    subplot_titles=("Unique peers by transport", "QUIC share of unique peers"),
    row_heights=[0.6, 0.4],
    vertical_spacing=0.12,
)

fig.add_trace(
    go.Scatter(
        x=df_daily_pivot["date"],
        y=df_daily_pivot["TCP"],
        name="TCP",
        mode="lines+markers",
        line=dict(color="#3b82f6"),
    ),
    row=1, col=1,
)
fig.add_trace(
    go.Scatter(
        x=df_daily_pivot["date"],
        y=df_daily_pivot["QUIC"],
        name="QUIC",
        mode="lines+markers",
        line=dict(color="#22c55e"),
    ),
    row=1, col=1,
)

fig.add_trace(
    go.Scatter(
        x=df_daily_pivot["date"],
        y=df_daily_pivot["QUIC %"],
        name="QUIC %",
        mode="lines+markers",
        line=dict(color="#22c55e"),
        fill="tozeroy",
        fillcolor="rgba(34, 197, 94, 0.2)",
        showlegend=False,
    ),
    row=2, col=1,
)

fig.update_yaxes(title_text="Unique peers", row=1, col=1)
fig.update_yaxes(title_text="QUIC %", range=[0, 20], row=2, col=1)
fig.update_xaxes(title_text="Date", row=2, col=1)

fig.update_layout(
    title="Daily unique peer trends",
    height=600,
    legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
fig.show()

Summary

Key findings from the transport protocol analysis.

Show code
from IPython.display import HTML, display

tcp_pct = df_overall[df_overall["transport"] == "tcp"]["percentage"].iloc[0]
quic_pct = df_overall[df_overall["transport"] == "udp"]["percentage"].iloc[0]

tcp_only_peers = int(df_peer_breakdown["tcp_only"].iloc[0])
quic_only_peers = int(df_peer_breakdown["quic_only"].iloc[0])
both_peers = int(df_peer_breakdown["both"].iloc[0])
total_peers = int(df_peer_breakdown["total"].iloc[0])

quic_clients = df_clients[df_clients["quic_capable_pct"] > 10]["client"].tolist()
no_quic_clients = df_clients[df_clients["quic_capable_pct"] < 1]["client"].tolist()

html = f'''
<style>
.summary-table {{ border-collapse: collapse; font-family: system-ui; width: 100%; }}
.summary-table th {{ background: #1e293b; color: white; padding: 12px; text-align: left; }}
.summary-table td {{ padding: 10px; border-bottom: 1px solid #e2e8f0; }}
.summary-table tr:hover {{ background: #f8fafc; }}
.metric {{ font-size: 1.5em; font-weight: bold; }}
.tcp {{ color: #3b82f6; }}
.quic {{ color: #22c55e; }}
</style>
<table class="summary-table">
<tr><th colspan="2">Connection distribution</th></tr>
<tr><td>TCP connections</td><td class="metric tcp">{tcp_pct:.1f}%</td></tr>
<tr><td>QUIC connections</td><td class="metric quic">{quic_pct:.1f}%</td></tr>
<tr><th colspan="2">Unique peer breakdown</th></tr>
<tr><td>TCP-only peers</td><td>{tcp_only_peers:,} ({tcp_only_peers/total_peers*100:.1f}%)</td></tr>
<tr><td>QUIC-only peers</td><td>{quic_only_peers:,} ({quic_only_peers/total_peers*100:.1f}%)</td></tr>
<tr><td>Dual-protocol peers</td><td>{both_peers:,} ({both_peers/total_peers*100:.1f}%)</td></tr>
<tr><td>Total unique peers</td><td><b>{total_peers:,}</b></td></tr>
<tr><th colspan="2">Client QUIC support</th></tr>
<tr><td>Significant QUIC support (&gt;10%)</td><td>{", ".join(quic_clients) or "None"}</td></tr>
<tr><td>No QUIC support (&lt;1%)</td><td>{", ".join(no_quic_clients) or "None"}</td></tr>
</table>
'''

display(HTML(html))
Connection distribution
TCP connections69.0%
QUIC connections31.0%
Unique peer breakdown
TCP-only peers24,688 (90.8%)
QUIC-only peers444 (1.6%)
Dual-protocol peers2,064 (7.6%)
Total unique peers27,196
Client QUIC support
Significant QUIC support (>10%)lighthouse, prysm, rust-libp2p, grandine
No QUIC support (<1%)nimbus, erigon, teku, lodestar, xatu