Fri, Jan 9, 2026 Latest

Missed slots

Analysis of missed slots on Ethereum mainnet.

A missed slot is a slot where the assigned proposer failed to produce a block. The chain skips these slots and continues. Typical miss rate is ~0.3%.

Definition: slot exists in proposer_duty AND no block exists in canonical_beacon_block

Show code
display_sql("block_production_timeline", target_date)
View query
WITH
-- Base slots using proposer duty as the source of truth
slots AS (
    SELECT DISTINCT
        slot,
        slot_start_date_time,
        proposer_validator_index
    FROM canonical_beacon_proposer_duty
    WHERE meta_network_name = 'mainnet'
      AND slot_start_date_time >= '2026-01-09' AND slot_start_date_time < '2026-01-09'::date + INTERVAL 1 DAY
),

-- Proposer entity mapping
proposer_entity AS (
    SELECT
        index,
        entity
    FROM ethseer_validator_entity
    WHERE meta_network_name = 'mainnet'
),

-- Blob count per slot
blob_count AS (
    SELECT
        slot,
        uniq(blob_index) AS blob_count
    FROM canonical_beacon_blob_sidecar
    WHERE meta_network_name = 'mainnet'
      AND slot_start_date_time >= '2026-01-09' AND slot_start_date_time < '2026-01-09'::date + INTERVAL 1 DAY
    GROUP BY slot
),

-- Canonical block hash (to verify MEV payload was actually used)
canonical_block AS (
    SELECT
        slot,
        execution_payload_block_hash
    FROM canonical_beacon_block
    WHERE meta_network_name = 'mainnet'
      AND slot_start_date_time >= '2026-01-09' AND slot_start_date_time < '2026-01-09'::date + INTERVAL 1 DAY
),

-- MEV bid timing using timestamp_ms
mev_bids AS (
    SELECT
        slot,
        slot_start_date_time,
        min(timestamp_ms) AS first_bid_timestamp_ms,
        max(timestamp_ms) AS last_bid_timestamp_ms
    FROM mev_relay_bid_trace
    WHERE meta_network_name = 'mainnet'
      AND slot_start_date_time >= '2026-01-09' AND slot_start_date_time < '2026-01-09'::date + INTERVAL 1 DAY
    GROUP BY slot, slot_start_date_time
),

-- MEV payload delivery - join canonical block with delivered payloads
-- Note: Use is_mev flag because ClickHouse LEFT JOIN returns 0 (not NULL) for non-matching rows
-- Get value from proposer_payload_delivered (not bid_trace, which may not have the winning block)
mev_payload AS (
    SELECT
        cb.slot,
        cb.execution_payload_block_hash AS winning_block_hash,
        1 AS is_mev,
        max(pd.value) AS winning_bid_value,
        groupArray(DISTINCT pd.relay_name) AS relay_names,
        any(pd.builder_pubkey) AS winning_builder
    FROM canonical_block cb
    GLOBAL INNER JOIN mev_relay_proposer_payload_delivered pd
        ON cb.slot = pd.slot AND cb.execution_payload_block_hash = pd.block_hash
    WHERE pd.meta_network_name = 'mainnet'
      AND slot_start_date_time >= '2026-01-09' AND slot_start_date_time < '2026-01-09'::date + INTERVAL 1 DAY
    GROUP BY cb.slot, cb.execution_payload_block_hash
),

-- Winning bid timing from bid_trace (may not exist for all MEV blocks)
winning_bid AS (
    SELECT
        bt.slot,
        bt.slot_start_date_time,
        argMin(bt.timestamp_ms, bt.event_date_time) AS winning_bid_timestamp_ms
    FROM mev_relay_bid_trace bt
    GLOBAL INNER JOIN mev_payload mp ON bt.slot = mp.slot AND bt.block_hash = mp.winning_block_hash
    WHERE bt.meta_network_name = 'mainnet'
      AND slot_start_date_time >= '2026-01-09' AND slot_start_date_time < '2026-01-09'::date + INTERVAL 1 DAY
    GROUP BY bt.slot, bt.slot_start_date_time
),

-- Block gossip timing with spread
block_gossip AS (
    SELECT
        slot,
        min(event_date_time) AS block_first_seen,
        max(event_date_time) AS block_last_seen
    FROM libp2p_gossipsub_beacon_block
    WHERE meta_network_name = 'mainnet'
      AND slot_start_date_time >= '2026-01-09' AND slot_start_date_time < '2026-01-09'::date + INTERVAL 1 DAY
    GROUP BY slot
),

-- Column arrival timing: first arrival per column, then min/max of those
column_gossip AS (
    SELECT
        slot,
        min(first_seen) AS first_column_first_seen,
        max(first_seen) AS last_column_first_seen
    FROM (
        SELECT
            slot,
            column_index,
            min(event_date_time) AS first_seen
        FROM libp2p_gossipsub_data_column_sidecar
        WHERE meta_network_name = 'mainnet'
          AND slot_start_date_time >= '2026-01-09' AND slot_start_date_time < '2026-01-09'::date + INTERVAL 1 DAY
          AND event_date_time > '1970-01-01 00:00:01'
        GROUP BY slot, column_index
    )
    GROUP BY slot
)

SELECT
    s.slot AS slot,
    s.slot_start_date_time AS slot_start_date_time,
    pe.entity AS proposer_entity,

    -- Blob count
    coalesce(bc.blob_count, 0) AS blob_count,

    -- MEV bid timing (absolute and relative to slot start)
    fromUnixTimestamp64Milli(mb.first_bid_timestamp_ms) AS first_bid_at,
    mb.first_bid_timestamp_ms - toInt64(toUnixTimestamp(mb.slot_start_date_time)) * 1000 AS first_bid_ms,
    fromUnixTimestamp64Milli(mb.last_bid_timestamp_ms) AS last_bid_at,
    mb.last_bid_timestamp_ms - toInt64(toUnixTimestamp(mb.slot_start_date_time)) * 1000 AS last_bid_ms,

    -- Winning bid timing (from bid_trace, may be NULL if block hash not in bid_trace)
    if(wb.slot != 0, fromUnixTimestamp64Milli(wb.winning_bid_timestamp_ms), NULL) AS winning_bid_at,
    if(wb.slot != 0, wb.winning_bid_timestamp_ms - toInt64(toUnixTimestamp(s.slot_start_date_time)) * 1000, NULL) AS winning_bid_ms,

    -- MEV payload info (from proposer_payload_delivered, always present for MEV blocks)
    if(mp.is_mev = 1, mp.winning_bid_value, NULL) AS winning_bid_value,
    if(mp.is_mev = 1, mp.relay_names, []) AS winning_relays,
    if(mp.is_mev = 1, mp.winning_builder, NULL) AS winning_builder,

    -- Block gossip timing with spread
    bg.block_first_seen,
    dateDiff('millisecond', s.slot_start_date_time, bg.block_first_seen) AS block_first_seen_ms,
    bg.block_last_seen,
    dateDiff('millisecond', s.slot_start_date_time, bg.block_last_seen) AS block_last_seen_ms,
    dateDiff('millisecond', bg.block_first_seen, bg.block_last_seen) AS block_spread_ms,

    -- Column arrival timing (NULL when no blobs)
    if(coalesce(bc.blob_count, 0) = 0, NULL, cg.first_column_first_seen) AS first_column_first_seen,
    if(coalesce(bc.blob_count, 0) = 0, NULL, dateDiff('millisecond', s.slot_start_date_time, cg.first_column_first_seen)) AS first_column_first_seen_ms,
    if(coalesce(bc.blob_count, 0) = 0, NULL, cg.last_column_first_seen) AS last_column_first_seen,
    if(coalesce(bc.blob_count, 0) = 0, NULL, dateDiff('millisecond', s.slot_start_date_time, cg.last_column_first_seen)) AS last_column_first_seen_ms,
    if(coalesce(bc.blob_count, 0) = 0, NULL, dateDiff('millisecond', cg.first_column_first_seen, cg.last_column_first_seen)) AS column_spread_ms

FROM slots s
GLOBAL LEFT JOIN proposer_entity pe ON s.proposer_validator_index = pe.index
GLOBAL LEFT JOIN blob_count bc ON s.slot = bc.slot
GLOBAL LEFT JOIN mev_bids mb ON s.slot = mb.slot
GLOBAL LEFT JOIN mev_payload mp ON s.slot = mp.slot
GLOBAL LEFT JOIN winning_bid wb ON s.slot = wb.slot
GLOBAL LEFT JOIN block_gossip bg ON s.slot = bg.slot
GLOBAL LEFT JOIN column_gossip cg ON s.slot = cg.slot

ORDER BY s.slot DESC
Show code
df = load_parquet("block_production_timeline", target_date)

# Identify missed slots: ClickHouse LEFT JOIN returns epoch date (1970-01-01) instead of NULL
# for non-matching rows, so we detect missed slots by checking for the epoch timestamp
epoch = pd.Timestamp("1970-01-01")
df["is_missed"] = df["block_first_seen"] == epoch

total_slots = len(df)
missed_slots = df["is_missed"].sum()
produced_slots = total_slots - missed_slots
miss_rate = missed_slots / total_slots * 100

print(f"Total slots: {total_slots:,}")
print(f"Blocks produced: {produced_slots:,} ({produced_slots/total_slots*100:.2f}%)")
print(f"Missed slots: {missed_slots:,} ({miss_rate:.2f}%)")
Total slots: 7,200
Blocks produced: 7,181 (99.74%)
Missed slots: 19 (0.26%)

Missed slots by proposer entity

Which staking entities had the most missed slots? Note that larger entities have more assigned slots, so absolute counts don't reflect performance.

Show code
# Missed slots by entity
df_missed = df[df["is_missed"]].copy()

if len(df_missed) > 0:
    # Fill empty entities
    df_missed["proposer_entity"] = df_missed["proposer_entity"].fillna("unknown").replace("", "unknown")
    
    entity_misses = df_missed.groupby("proposer_entity").size().reset_index(name="missed_count")
    entity_misses = entity_misses.sort_values("missed_count", ascending=True)
    
    fig = go.Figure()
    fig.add_trace(go.Bar(
        y=entity_misses["proposer_entity"],
        x=entity_misses["missed_count"],
        orientation="h",
        marker_color="#e74c3c",
        text=entity_misses["missed_count"],
        textposition="outside",
    ))
    fig.update_layout(
        margin=dict(l=150, r=50, t=30, b=60),
        xaxis=dict(title="Missed slots"),
        yaxis=dict(title=""),
        height=max(300, len(entity_misses) * 25 + 100),
    )
    fig.show(config={"responsive": True})
else:
    print("No missed slots today.")

Entity miss rates

Normalized view: what percentage of each entity's assigned slots were missed? This accounts for entity size.

Show code
if len(df_missed) > 0:
    # Calculate miss rate per entity
    df["proposer_entity_clean"] = df["proposer_entity"].fillna("unknown").replace("", "unknown")
    
    entity_stats = df.groupby("proposer_entity_clean").agg(
        total_slots=("slot", "count"),
        missed_slots=("is_missed", "sum")
    ).reset_index()
    entity_stats["miss_rate"] = entity_stats["missed_slots"] / entity_stats["total_slots"] * 100
    
    # Only show entities with at least 1 missed slot
    entity_stats = entity_stats[entity_stats["missed_slots"] > 0]
    entity_stats = entity_stats.sort_values("miss_rate", ascending=True)
    
    # Color by miss rate
    fig = go.Figure()
    fig.add_trace(go.Bar(
        y=entity_stats["proposer_entity_clean"],
        x=entity_stats["miss_rate"],
        orientation="h",
        marker_color=entity_stats["miss_rate"],
        marker_colorscale="YlOrRd",
        text=entity_stats.apply(lambda r: f"{r['miss_rate']:.1f}% ({int(r['missed_slots'])}/{int(r['total_slots'])})", axis=1),
        textposition="outside",
        hovertemplate="<b>%{y}</b><br>Miss rate: %{x:.2f}%<extra></extra>",
    ))
    fig.update_layout(
        margin=dict(l=150, r=100, t=30, b=60),
        xaxis=dict(title="Miss rate (%)", range=[0, max(entity_stats["miss_rate"]) * 1.3]),
        yaxis=dict(title=""),
        height=max(300, len(entity_stats) * 25 + 100),
    )
    fig.show(config={"responsive": True})

Missed slots by time of day

Are there patterns in when slots are missed? Spikes could indicate coordinated issues or network events.

Show code
if len(df_missed) > 0:
    # Extract hour from slot time
    df_missed["hour"] = pd.to_datetime(df_missed["slot_start_date_time"]).dt.hour
    
    hourly_misses = df_missed.groupby("hour").size().reset_index(name="missed_count")
    
    # Fill in missing hours with 0
    all_hours = pd.DataFrame({"hour": range(24)})
    hourly_misses = all_hours.merge(hourly_misses, on="hour", how="left").fillna(0)
    hourly_misses["missed_count"] = hourly_misses["missed_count"].astype(int)
    
    fig = go.Figure()
    fig.add_trace(go.Bar(
        x=hourly_misses["hour"],
        y=hourly_misses["missed_count"],
        marker_color="#e74c3c",
    ))
    fig.update_layout(
        margin=dict(l=60, r=30, t=30, b=60),
        xaxis=dict(title="Hour (UTC)", tickmode="linear", dtick=2),
        yaxis=dict(title="Missed slots"),
        height=350,
    )
    fig.show(config={"responsive": True})

Missed slots timeline

When did each missed slot occur? Clusters may indicate network-wide issues.

Show code
if len(df_missed) > 0:
    df_plot = df_missed.copy()
    df_plot["time"] = pd.to_datetime(df_plot["slot_start_date_time"])
    
    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=df_plot["time"],
        y=[1] * len(df_plot),  # All at same y level
        mode="markers",
        marker=dict(size=10, color="#e74c3c", symbol="x"),
        customdata=np.column_stack([df_plot["slot"], df_plot["proposer_entity"]]),
        hovertemplate="<b>Slot %{customdata[0]}</b><br>Time: %{x}<br>Entity: %{customdata[1]}<extra></extra>",
    ))
    fig.update_layout(
        margin=dict(l=60, r=30, t=30, b=60),
        xaxis=dict(title="Time (UTC)", tickformat="%H:%M"),
        yaxis=dict(visible=False),
        height=200,
    )
    fig.show(config={"responsive": True})

All missed slots

Complete list of missed slots with proposer details.

Show code
if len(df_missed) > 0:
    df_table = df_missed[["slot", "slot_start_date_time", "proposer_entity"]].copy()
    df_table["proposer_entity"] = df_table["proposer_entity"].fillna("unknown").replace("", "unknown")
    df_table["time"] = pd.to_datetime(df_table["slot_start_date_time"]).dt.strftime("%H:%M:%S")
    df_table = df_table.sort_values("slot")
    
    # Create Lab links
    df_table["lab_link"] = df_table["slot"].apply(
        lambda s: f'<a href="https://lab.ethpandaops.io/ethereum/slots/{s}" target="_blank">View</a>'
    )
    
    # Build HTML table
    html = '''
    <style>
    .missed-table { border-collapse: collapse; width: 100%; font-family: monospace; font-size: 13px; }
    .missed-table th { background: #c0392b; color: white; padding: 8px 12px; text-align: left; position: sticky; top: 0; }
    .missed-table td { padding: 6px 12px; border-bottom: 1px solid #eee; }
    .missed-table tr:hover { background: #ffebee; }
    .missed-table a { color: #1976d2; text-decoration: none; }
    .missed-table a:hover { text-decoration: underline; }
    .table-container { max-height: 500px; overflow-y: auto; }
    </style>
    <div class="table-container">
    <table class="missed-table">
    <thead>
    <tr><th>Slot</th><th>Time (UTC)</th><th>Proposer entity</th><th>Lab</th></tr>
    </thead>
    <tbody>
    '''
    
    for _, row in df_table.iterrows():
        html += f'''<tr>
            <td>{row["slot"]}</td>
            <td>{row["time"]}</td>
            <td>{row["proposer_entity"]}</td>
            <td>{row["lab_link"]}</td>
        </tr>'''
    
    html += '</tbody></table></div>'
    display(HTML(html))
    print(f"\nTotal missed slots: {len(df_table):,}")
else:
    print("No missed slots today.")
SlotTime (UTC)Proposer entityLab
13424577 00:35:47 solo_stakers View
13424701 01:00:35 solo_stakers View
13424830 01:26:23 solo_stakers View
13425237 02:47:47 rocketpool View
13425248 02:49:59 binance View
13425408 03:21:59 ether.fi View
13426247 06:09:47 solo_stakers View
13427059 08:52:11 ether.fi View
13427239 09:28:11 solo_stakers View
13427809 11:22:11 solo_stakers View
13428127 12:25:47 rocketpool View
13428435 13:27:23 whale_0xb83e View
13428835 14:47:23 solo_stakers View
13429253 16:10:59 unknown View
13429564 17:13:11 solo_stakers View
13430296 19:39:35 solo_stakers View
13430508 20:21:59 rocketpool View
13430613 20:42:59 solo_stakers View
13430851 21:30:35 rocketpool View
Total missed slots: 19