Tue, Jan 13, 2026 Latest

Block propagation

Analysis of block propagation latency across Ethereum mainnet.

Methodology: Measures time from slot start to when Xatu sentry nodes first observe the block via gossipsub. The "spread" shows how long it takes for all sentry nodes to see the block after the fastest node.

Show code
display_sql("block_propagation", target_date)
View query
WITH first_seen_per_node AS (
    -- Get first observation of each block per sentry node
    SELECT
        slot,
        slot_start_date_time,
        epoch,
        meta_client_name AS node_name,
        MIN(propagation_slot_start_diff) AS arrival_ms
    FROM libp2p_gossipsub_beacon_block FINAL
    WHERE slot_start_date_time >= '2026-01-13' AND slot_start_date_time < '2026-01-13'::date + INTERVAL 1 DAY
      AND meta_network_name = 'mainnet'
      AND startsWith(meta_client_name, 'ethpandaops/mainnet/')
    GROUP BY slot, slot_start_date_time, epoch, meta_client_name
),
slot_stats AS (
    -- Per-slot: first arrival (fastest node) and spread (slowest - fastest)
    SELECT
        slot,
        slot_start_date_time,
        epoch,
        MIN(arrival_ms) AS first_arrival_ms,
        MAX(arrival_ms) AS last_arrival_ms,
        MAX(arrival_ms) - MIN(arrival_ms) AS spread_ms,
        COUNT(DISTINCT node_name) AS node_count
    FROM first_seen_per_node
    GROUP BY slot, slot_start_date_time, epoch
)
SELECT
    epoch,
    MIN(slot_start_date_time) AS epoch_start,
    COUNT(*) AS slot_count,
    AVG(node_count) AS avg_nodes,

    -- Arrival time percentiles (time from slot start to first observation)
    quantile(0.50)(first_arrival_ms) AS p50_ms,
    quantile(0.75)(first_arrival_ms) AS p75_ms,
    quantile(0.80)(first_arrival_ms) AS p80_ms,
    quantile(0.85)(first_arrival_ms) AS p85_ms,
    quantile(0.90)(first_arrival_ms) AS p90_ms,
    quantile(0.95)(first_arrival_ms) AS p95_ms,
    quantile(0.99)(first_arrival_ms) AS p99_ms,

    -- Spread percentiles (time for all nodes to see the block)
    quantile(0.50)(spread_ms) AS spread_p50_ms,
    quantile(0.75)(spread_ms) AS spread_p75_ms,
    quantile(0.90)(spread_ms) AS spread_p90_ms,
    quantile(0.95)(spread_ms) AS spread_p95_ms,
    quantile(0.99)(spread_ms) AS spread_p99_ms,

    -- Tail analysis
    MAX(first_arrival_ms) AS max_arrival_ms,
    MAX(spread_ms) AS max_spread_ms

FROM slot_stats
GROUP BY epoch
ORDER BY epoch
Show code
df = load_parquet("block_propagation", target_date)

# Convert epoch_start to datetime
df["epoch_start"] = pd.to_datetime(df["epoch_start"])

total_epochs = len(df)
total_slots = df["slot_count"].sum()
avg_nodes = df["avg_nodes"].mean()

# Overall percentiles (weighted by slot count)
p50_overall = np.average(df["p50_ms"], weights=df["slot_count"])
p90_overall = np.average(df["p90_ms"], weights=df["slot_count"])
p99_overall = np.average(df["p99_ms"], weights=df["slot_count"])

print(f"Epochs: {total_epochs:,}")
print(f"Slots: {total_slots:,}")
print(f"Average sentry nodes: {avg_nodes:.1f}")
print(f"\nBlock arrival latency (from slot start):")
print(f"  p50: {p50_overall:.0f} ms ({p50_overall/1000:.2f} s)")
print(f"  p90: {p90_overall:.0f} ms ({p90_overall/1000:.2f} s)")
print(f"  p99: {p99_overall:.0f} ms ({p99_overall/1000:.2f} s)")
Epochs: 226
Slots: 7,170
Average sentry nodes: 62.7

Block arrival latency (from slot start):
  p50: 1724 ms (1.72 s)
  p90: 2706 ms (2.71 s)
  p99: 3252 ms (3.25 s)

Block arrival latency over time

Time from slot start to when the block is first observed by any sentry node. Lower is better. Each line represents a percentile of the distribution within each epoch.

Show code
# Melt percentile columns for line chart
percentile_cols = ["p50_ms", "p75_ms", "p80_ms", "p85_ms", "p90_ms", "p95_ms", "p99_ms"]
df_long = df.melt(
    id_vars=["epoch", "epoch_start"],
    value_vars=percentile_cols,
    var_name="percentile",
    value_name="latency_ms"
)
df_long["latency_s"] = df_long["latency_ms"] / 1000
df_long["percentile_label"] = df_long["percentile"].str.replace("_ms", "").str.upper()

# Color scheme for percentiles (darker = higher percentile)
colors = {
    "P50": "#2ecc71",
    "P75": "#3498db",
    "P80": "#9b59b6",
    "P85": "#e67e22",
    "P90": "#e74c3c",
    "P95": "#c0392b",
    "P99": "#7b241c",
}

fig = px.line(
    df_long,
    x="epoch_start",
    y="latency_s",
    color="percentile_label",
    color_discrete_map=colors,
    labels={"epoch_start": "Time", "latency_s": "Latency (seconds)", "percentile_label": "Percentile"},
    category_orders={"percentile_label": ["P50", "P75", "P80", "P85", "P90", "P95", "P99"]},
)
fig.update_traces(line=dict(width=2))
fig.update_layout(
    margin=dict(l=60, r=30, t=30, b=60),
    legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
    height=450,
    xaxis=dict(title="Time (UTC)"),
)
fig.show(config={"responsive": True})

Network spread over time

Time between the fastest and slowest sentry node observing each block. This measures how synchronized the network view is. Lower spread means more consistent propagation.

Show code
# Spread percentiles
spread_cols = ["spread_p50_ms", "spread_p75_ms", "spread_p90_ms", "spread_p95_ms", "spread_p99_ms"]
df_spread = df.melt(
    id_vars=["epoch", "epoch_start"],
    value_vars=spread_cols,
    var_name="percentile",
    value_name="spread_ms"
)
df_spread["spread_s"] = df_spread["spread_ms"] / 1000
df_spread["percentile_label"] = df_spread["percentile"].str.replace("spread_", "").str.replace("_ms", "").str.upper()

spread_colors = {
    "P50": "#27ae60",
    "P75": "#2980b9",
    "P90": "#8e44ad",
    "P95": "#d35400",
    "P99": "#c0392b",
}

fig = px.line(
    df_spread,
    x="epoch_start",
    y="spread_s",
    color="percentile_label",
    color_discrete_map=spread_colors,
    labels={"epoch_start": "Time", "spread_s": "Spread (seconds)", "percentile_label": "Percentile"},
    category_orders={"percentile_label": ["P50", "P75", "P90", "P95", "P99"]},
)
fig.update_traces(line=dict(width=2))
fig.update_layout(
    margin=dict(l=60, r=30, t=30, b=60),
    legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
    height=400,
    xaxis=dict(title="Time (UTC)"),
)
fig.show(config={"responsive": True})

Latency distribution

Distribution of p50 arrival latencies across all epochs. The box plot shows the variability throughout the day.

Show code
# Create histogram of p50 latencies
fig = make_subplots(rows=1, cols=2, subplot_titles=("P50 latency distribution", "P99 latency distribution"))

fig.add_trace(
    go.Histogram(x=df["p50_ms"], nbinsx=30, marker_color="#2ecc71", name="P50"),
    row=1, col=1
)
fig.add_trace(
    go.Histogram(x=df["p99_ms"], nbinsx=30, marker_color="#e74c3c", name="P99"),
    row=1, col=2
)

fig.update_xaxes(title_text="Latency (ms)", row=1, col=1)
fig.update_xaxes(title_text="Latency (ms)", row=1, col=2)
fig.update_yaxes(title_text="Epoch count", row=1, col=1)
fig.update_yaxes(title_text="Epoch count", row=1, col=2)

fig.update_layout(
    margin=dict(l=60, r=30, t=60, b=60),
    showlegend=False,
    height=350,
)
fig.show(config={"responsive": True})

Spread vs arrival latency

Relationship between how fast the first node sees a block and how long it takes all nodes to see it. Blocks that arrive late often have larger spread.

Show code
fig = px.scatter(
    df,
    x="p50_ms",
    y="spread_p50_ms",
    opacity=0.6,
    labels={"p50_ms": "P50 arrival latency (ms)", "spread_p50_ms": "P50 spread (ms)"},
    hover_data={"epoch": True, "slot_count": True},
)
fig.update_traces(marker=dict(size=8, color="#3498db"))
fig.update_layout(
    margin=dict(l=60, r=30, t=30, b=60),
    height=400,
)
fig.show(config={"responsive": True})

Summary statistics

Daily summary of block propagation performance.

Show code
# Summary table
summary = {
    "Metric": [
        "Total epochs",
        "Total slots",
        "Avg sentry nodes",
        "P50 arrival (ms)",
        "P75 arrival (ms)",
        "P90 arrival (ms)",
        "P95 arrival (ms)",
        "P99 arrival (ms)",
        "Max arrival (ms)",
        "P50 spread (ms)",
        "P90 spread (ms)",
        "P99 spread (ms)",
        "Max spread (ms)",
    ],
    "Value": [
        f"{total_epochs:,}",
        f"{total_slots:,}",
        f"{avg_nodes:.1f}",
        f"{p50_overall:.0f}",
        f"{np.average(df['p75_ms'], weights=df['slot_count']):.0f}",
        f"{p90_overall:.0f}",
        f"{np.average(df['p95_ms'], weights=df['slot_count']):.0f}",
        f"{p99_overall:.0f}",
        f"{df['max_arrival_ms'].max():.0f}",
        f"{np.average(df['spread_p50_ms'], weights=df['slot_count']):.0f}",
        f"{np.average(df['spread_p90_ms'], weights=df['slot_count']):.0f}",
        f"{np.average(df['spread_p99_ms'], weights=df['slot_count']):.0f}",
        f"{df['max_spread_ms'].max():.0f}",
    ],
}

pd.DataFrame(summary)
Metric Value
0 Total epochs 226
1 Total slots 7,170
2 Avg sentry nodes 62.7
3 P50 arrival (ms) 1724
4 P75 arrival (ms) 2338
5 P90 arrival (ms) 2706
6 P95 arrival (ms) 2941
7 P99 arrival (ms) 3252
8 Max arrival (ms) 9833
9 P50 spread (ms) 603
10 P90 spread (ms) 871
11 P99 spread (ms) 1238
12 Max spread (ms) 4201