Fri, Dec 26, 2025

Attestation arrival - 2025-12-26

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-26' AND slot_start_date_time < '2025-12-26'::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,198
Attestations observed: 1,235,137,250

Attestation arrival latency (from slot start):
  p50: 4519 ms (4.52 s)
  p90: 7318 ms (7.32 s)
  p99: 13276 ms (13.28 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-26' AND slot_start_date_time < '2025-12-26'::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,235,137,250

Cross-committee medians:
  P50: 4526 ms
  P99: 12752 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 > 19128 ms (19.1s):
Committee Attestations P50 (ms) P90 (ms) P99 (ms) Max (ms)
35 35 15067128 4918 8249 21304 711922
33 33 15112473 4728 8333 20855 633092
34 34 14455001 4870 8385 20137 623660

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,198
2 Attestations observed 1,235,137,250
3 Committees (subnets) 64
4 P50 arrival (ms) 4519
5 P75 arrival (ms) 5718
6 P90 arrival (ms) 7318
7 P95 arrival (ms) 8534
8 P99 arrival (ms) 13276
9 Max arrival (ms) 23290366
10 Slow committees (P99 > 1.5x median) 3