Block propagation - 2026-01-12
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
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)")
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)