Thu, Dec 18, 2025

Attestation arrival - 2025-12-18

Analysis of attestation arrival latency across Ethereum mainnet.

Methodology: Measures time from slot start to when Xatu sentry nodes first observe attestations via gossipsub. Attestations are broadcast on 64 subnets (committees). Late attestations (>4s) may miss inclusion in the next block.

Show code
display_sql("attestation_propagation", target_date)
View query
SELECT
    toStartOfHour(slot_start_date_time) AS hour,
    COUNT(*) AS attestation_count,
    uniqExact(slot) AS slot_count,

    -- Arrival time percentiles (using propagation_slot_start_diff directly)
    quantile(0.50)(propagation_slot_start_diff) AS p50_ms,
    quantile(0.75)(propagation_slot_start_diff) AS p75_ms,
    quantile(0.80)(propagation_slot_start_diff) AS p80_ms,
    quantile(0.85)(propagation_slot_start_diff) AS p85_ms,
    quantile(0.90)(propagation_slot_start_diff) AS p90_ms,
    quantile(0.95)(propagation_slot_start_diff) AS p95_ms,
    quantile(0.99)(propagation_slot_start_diff) AS p99_ms,

    -- Distribution stats
    AVG(propagation_slot_start_diff) AS avg_ms,
    stddevSamp(propagation_slot_start_diff) AS std_ms,
    MIN(propagation_slot_start_diff) AS min_ms,
    MAX(propagation_slot_start_diff) AS max_ms

FROM libp2p_gossipsub_beacon_attestation
WHERE slot_start_date_time >= '2025-12-18' AND slot_start_date_time < '2025-12-18'::date + INTERVAL 1 DAY
  AND meta_network_name = 'mainnet'
  AND startsWith(meta_client_name, 'ethpandaops/mainnet/')
GROUP BY hour
ORDER BY hour
Show code
df = load_parquet("attestation_propagation", target_date)

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

total_hours = len(df)
total_attestations = df["attestation_count"].sum()
total_slots = df["slot_count"].sum()

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

print(f"Hours: {total_hours:,}")
print(f"Slots: {total_slots:,}")
print(f"Attestations observed: {total_attestations:,}")
print(f"\nAttestation 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)")
Hours: 24
Slots: 7,200
Attestations observed: 1,157,873,165

Attestation arrival latency (from slot start):
  p50: 4539 ms (4.54 s)
  p90: 7452 ms (7.45 s)
  p99: 13235 ms (13.23 s)

Attestation arrival latency over time

Time from slot start to when attestations are first observed. Each line represents a percentile of the distribution within each hour. Attestations arriving after 4 seconds (dashed line) may miss the next block.

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=["hour"],
    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
colors = {
    "P50": "#2ecc71",
    "P75": "#3498db",
    "P80": "#9b59b6",
    "P85": "#e67e22",
    "P90": "#e74c3c",
    "P95": "#c0392b",
    "P99": "#7b241c",
}

fig = px.line(
    df_long,
    x="hour",
    y="latency_s",
    color="percentile_label",
    color_discrete_map=colors,
    labels={"hour": "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))

# Add 4-second reference line (attestation deadline)
fig.add_hline(y=4, line_dash="dash", line_color="gray", annotation_text="4s deadline", annotation_position="right")

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})

Latency distribution

Distribution of per-hour median (P50) and tail (P99) attestation latencies throughout the day.

Show code
# Create histogram of 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"] / 1000, nbinsx=30, marker_color="#2ecc71", name="P50"),
    row=1, col=1
)
fig.add_trace(
    go.Histogram(x=df["p99_ms"] / 1000, nbinsx=30, marker_color="#e74c3c", name="P99"),
    row=1, col=2
)

fig.update_xaxes(title_text="Latency (s)", row=1, col=1)
fig.update_xaxes(title_text="Latency (s)", row=1, col=2)
fig.update_yaxes(title_text="Hour count", row=1, col=1)
fig.update_yaxes(title_text="Hour 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})

Committee (subnet) analysis

Per-committee latency summary for the day. Committees with significantly higher latency may indicate subnet-specific issues.

Show code
display_sql("attestation_by_committee", target_date)
View query
SELECT
    attesting_validator_committee_index AS committee,
    COUNT(*) AS attestation_count,
    uniqExact(slot) AS slot_count,

    -- Arrival time percentiles
    quantile(0.50)(propagation_slot_start_diff) AS p50_ms,
    quantile(0.75)(propagation_slot_start_diff) AS p75_ms,
    quantile(0.90)(propagation_slot_start_diff) AS p90_ms,
    quantile(0.95)(propagation_slot_start_diff) AS p95_ms,
    quantile(0.99)(propagation_slot_start_diff) AS p99_ms,

    -- Stats for comparison
    AVG(propagation_slot_start_diff) AS avg_ms,
    MAX(propagation_slot_start_diff) AS max_ms

FROM libp2p_gossipsub_beacon_attestation
WHERE slot_start_date_time >= '2025-12-18' AND slot_start_date_time < '2025-12-18'::date + INTERVAL 1 DAY
  AND meta_network_name = 'mainnet'
  AND startsWith(meta_client_name, 'ethpandaops/mainnet/')
  AND attesting_validator_committee_index != ''
GROUP BY attesting_validator_committee_index
ORDER BY toInt32OrNull(attesting_validator_committee_index)
Show code
df_comm = load_parquet("attestation_by_committee", target_date)

# Convert committee to int for proper sorting
df_comm["committee_num"] = pd.to_numeric(df_comm["committee"], errors="coerce").fillna(-1).astype(int)
df_comm = df_comm.sort_values("committee_num")

# Calculate overall median for comparison
overall_p50 = df_comm["p50_ms"].median()
overall_p99 = df_comm["p99_ms"].median()

print(f"Committees: {len(df_comm)}")
print(f"Total attestations: {df_comm['attestation_count'].sum():,}")
print(f"\nCross-committee medians:")
print(f"  P50: {overall_p50:.0f} ms")
print(f"  P99: {overall_p99:.0f} ms")
Committees: 64
Total attestations: 1,157,873,165

Cross-committee medians:
  P50: 4537 ms
  P99: 13226 ms
Show code
# Bar chart of P50 and P99 by committee
fig = go.Figure()

fig.add_trace(go.Bar(
    x=df_comm["committee"],
    y=df_comm["p50_ms"] / 1000,
    name="P50",
    marker_color="#2ecc71",
))
fig.add_trace(go.Bar(
    x=df_comm["committee"],
    y=df_comm["p99_ms"] / 1000,
    name="P99",
    marker_color="#e74c3c",
))

# Add reference lines
fig.add_hline(y=4, line_dash="dash", line_color="gray", annotation_text="4s deadline")

fig.update_layout(
    barmode="group",
    margin=dict(l=60, r=30, t=30, b=60),
    xaxis=dict(title="Committee (subnet)", tickangle=45, dtick=4),
    yaxis=dict(title="Latency (seconds)"),
    legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
    height=400,
)
fig.show(config={"responsive": True})

Slow committees

Committees with P99 latency significantly above the median. These subnets may have connectivity or performance issues.

Show code
# Identify slow committees (P99 > 1.5x median P99)
threshold = overall_p99 * 1.5
df_slow = df_comm[df_comm["p99_ms"] > threshold].copy()
df_slow = df_slow.sort_values("p99_ms", ascending=False)

if len(df_slow) > 0:
    print(f"Committees with P99 > {threshold:.0f} ms ({threshold/1000:.1f}s):")
    display_df = df_slow[["committee", "attestation_count", "p50_ms", "p90_ms", "p99_ms", "max_ms"]].copy()
    display_df.columns = ["Committee", "Attestations", "P50 (ms)", "P90 (ms)", "P99 (ms)", "Max (ms)"]
    for col in ["P50 (ms)", "P90 (ms)", "P99 (ms)", "Max (ms)"]:
        display_df[col] = display_df[col].round(0).astype(int)
    display(display_df)
else:
    print(f"No committees with P99 > {threshold:.0f} ms - all subnets performing within normal range.")
Committees with P99 > 19840 ms (19.8s):
Committee Attestations P50 (ms) P90 (ms) P99 (ms) Max (ms)
32 32 13679501 4958 8957 23201 620748
16 16 15587840 4826 8293 22119 711105
17 17 14237444 4844 8466 21942 726908
61 61 15863481 4934 8342 21421 715231
31 31 13779712 5006 8345 21301 667710
15 15 15872955 4613 8682 20893 636521
18 18 13893770 4762 8718 20841 662632
19 19 13370885 4790 8500 20157 673441

Committee latency heatmap

Visual comparison of latency percentiles across all 64 committees.

Show code
# Prepare heatmap data
heatmap_cols = ["p50_ms", "p75_ms", "p90_ms", "p95_ms", "p99_ms"]
heatmap_data = df_comm.set_index("committee")[heatmap_cols].T
heatmap_data.index = ["P50", "P75", "P90", "P95", "P99"]

# Convert to seconds for display
heatmap_data = heatmap_data / 1000

fig = go.Figure(data=go.Heatmap(
    z=heatmap_data.values,
    x=heatmap_data.columns,
    y=heatmap_data.index,
    colorscale="YlOrRd",
    colorbar=dict(title="Latency (s)"),
))

fig.update_layout(
    margin=dict(l=60, r=30, t=30, b=60),
    xaxis=dict(title="Committee", tickangle=45, dtick=4),
    yaxis=dict(title="Percentile", autorange="reversed"),
    height=300,
)
fig.show(config={"responsive": True})

Summary statistics

Show code
# Summary table
summary = {
    "Metric": [
        "Total hours",
        "Total slots",
        "Attestations observed",
        "Committees (subnets)",
        "P50 arrival (ms)",
        "P75 arrival (ms)",
        "P90 arrival (ms)",
        "P95 arrival (ms)",
        "P99 arrival (ms)",
        "Max arrival (ms)",
        "Slow committees (P99 > 1.5x median)",
    ],
    "Value": [
        f"{total_hours:,}",
        f"{total_slots:,}",
        f"{total_attestations:,}",
        f"{len(df_comm)}",
        f"{p50_overall:.0f}",
        f"{np.average(df['p75_ms'], weights=df['attestation_count']):.0f}",
        f"{p90_overall:.0f}",
        f"{np.average(df['p95_ms'], weights=df['attestation_count']):.0f}",
        f"{p99_overall:.0f}",
        f"{df['max_ms'].max():.0f}",
        f"{len(df_slow)}",
    ],
}

pd.DataFrame(summary)
Metric Value
0 Total hours 24
1 Total slots 7,200
2 Attestations observed 1,157,873,165
3 Committees (subnets) 64
4 P50 arrival (ms) 4539
5 P75 arrival (ms) 5783
6 P90 arrival (ms) 7452
7 P95 arrival (ms) 8783
8 P99 arrival (ms) 13235
9 Max arrival (ms) 4294967278
10 Slow committees (P99 > 1.5x median) 8