Reconnect Yellowstone gRPC Without Missing Transactions

Resume a dropped Yellowstone gRPC stream with from_slot: spot the drop, pick the resume slot, drop duplicates, handle replay errors. Rust and Node.js code.

  • October 29, 2026
  • Infrastructure
  • AllenHark Team
A quiet stack of pagesAn abstract drawing of light on the page's dark ground. A neat stack of thin, solid pages, the top one lit, with faint streams of light drifting toward it from the left.A quiet stack of pagesAn abstract drawing of light on the page's dark ground. A neat stack of thin, solid pages, the top one lit, with faint streams of light drifting toward it from the left.

Every long-running gRPC stream drops sooner or later: a network blip, a deploy on your side, a server restart on the provider's side, or a stream cut for reading too slowly. When it drops, everything that happens until you are back is gone, unless you ask for it again. Yellowstone lets you do that with from_slot. This post covers the four parts of a reconnect that loses nothing: noticing the drop, choosing the slot to resume from, removing the duplicates the replay sends, and handling a replay the server refuses. It ends with working code in Rust and Node.js.

First: do you want the missed data?

Not every bot does. A sniper or an arbitrage bot acts on the newest state, and a launch from 30 seconds ago is no use to it. For those, reconnect at the tip, re-read the accounts you track, and carry on; replaying the gap only delays you. Indexers, payment tracking, copy trading and anything that counts or records events need every transaction. The rest of this post is for them.

How replay works

from_slot is a field of SubscribeRequest. When it is set, the server sends the stored updates that match your filters from that slot onwards, then carries on with live updates on the same stream. Three rules follow from how the server works:

  • The server keeps nothing about you between connections. A new Subscribe call needs the full request again: every filter, the commitment level and from_slot.
  • The window is the provider's buffer. A slot older than what the server still holds is refused. SubscribeReplayInfo returns first_available, the oldest slot you can ask for right now.
  • Replay overlaps what you already have if you resume from a slot you partly received. You remove the overlap yourself.

How far back each provider replays

Disclosure: AllenHark sells Yellowstone gRPC. Every other provider's window below comes from its own docs, linked.

How we compared: each window is as the provider's docs state it, checked 28 October 2026. Where a provider gives a slot count, we converted it at about 200 ms a slot, the slot time we measured on mainnet that day. Slots got shorter in October 2026, so a window set in slots now covers about half the time it did at 400 ms.

ProviderReplay windowIn time
AllenHarkAbout 48 hoursAbout 48 hours
Helius LaserStreamAbout 48 hours (fromSlot); older than about 20 minutes returns finalized data onlyAbout 48 hours
AlchemyLast 6,000 slotsAbout 20 minutes
QuickNode"About the last 11 minutes of slots"About 11 minutes
Shyft150 slotsAbout 30 seconds
Chainstack Global NodeAbout 100 slots; larger on Dedicated NodesAbout 20 seconds
Triton Dragon's MouthNot published; Fumarole keeps up to 4 daysNot published
Self-hosted Yellowstone pluginreplay_stored_slots, 150 by default since April 2026About 30 seconds by default

A 30-second window covers a quick reconnect, not a deploy or a restart. If your window is short, plan to fill longer gaps from RPC (see "When the replay is refused" below). The provider comparison covers the other differences.

Step 1: notice the drop quickly

A stream that ends with an error is the easy case. The hard case is a connection that dies without a clean close, so your client waits for data that will never come. Three things catch it:

  • A watchdog. Add a slots filter with filter_by_commitment: true. You get one slot update per slot, about every 200 ms. If nothing arrives for 5 seconds, cancel the stream and reconnect.
  • Pings. Yellowstone servers send a ping update on every open stream (every 10 seconds in the current open-source code). Reply with a request that carries only ping: { id: 1 }; it does not change your filters.
  • HTTP/2 keepalive, so a dead connection fails instead of hanging. Keep the interval at 10 seconds or more, since gRPC servers can close connections that ping too often.

The lag and missing-events guide covers each of these in more detail.

Step 2: pick the slot to resume from

Resume from the last slot you finished, not the last slot you saw. A slot is finished when you have every update for it, and when that is depends on your commitment level.

At confirmed or finalized, Yellowstone sends one block per slot, in order: the slot's data, then its block meta, then its status. When the slot status for slot S arrives, everything for S has arrived. Keep the highest such slot as your checkpoint and resume with from_slot set to it. Resuming from the checkpoint itself, not the slot after it, costs one slot of duplicates and guards against an update that arrived after the status. Under Alpenglow, finality takes about 150 ms (the Solana Foundation's figure), so for a stream that must not miss anything finalized is now the simple choice.

At processed, a slot can have more than one candidate block, each with its own bank_id, and the server makes no ordering promise between data and status. Resume from before the oldest slot that is not yet confirmed. bank_id is a counter local to one connection, so clear all bank state when you reconnect. Our Alpenglow guide has the buffering code. In Rust, subscribe_with_reconnect (below) does all of this for you.

Step 3: remove the duplicates

Replay overlaps what you have, so make every handler safe to run twice on the same event:

  • Transactions: key by signature.
  • Account writes: key by pubkey, slot and write_version.
  • Blocks: key by slot and blockhash from the block meta, not by bank_id, which changes between connections.

The dedupe store only has to cover the replay window you use, so prune it by slot as slots finish.

Step 4: retry the right errors, the right way

Some errors mean the stream broke and a reconnect will fix it. Others mean your request or your access is wrong, and retrying only hammers the server. The official Rust client draws the line like this:

gRPC statusRetry?Usual meaning
UNAVAILABLE, INTERNAL, UNKNOWN, CANCELLED, ABORTED, DEADLINE_EXCEEDED, DATA_LOSSYesThe connection or the server broke. INTERNAL: lagged to send an update means your client read too slowly.
RESOURCE_EXHAUSTEDYes, once the old stream is closedOn AllenHark: more streams from one address than your plan allows.
OUT_OF_RANGENot with the same from_slotThe slot is older than the replay window.
PERMISSION_DENIED, UNAUTHENTICATEDNoOn AllenHark, PERMISSION_DENIED: Unauthorized IP means the address is not on your allowlist. The header is x-token, not x-api-key.
INVALID_ARGUMENT, UNIMPLEMENTED, FAILED_PRECONDITIONNoFix the request.

Two habits make retries safe:

  • Close the old stream before you open the new one. If the old one is still open on the server, the new one can count against your stream limit and fail with RESOURCE_EXHAUSTED.
  • Back off, with jitter. Start at a few hundred milliseconds, double each time, cap at about 30 seconds, randomise each wait, and reset after a healthy stream.

When the replay is refused

The open-source server answers a slot older than its buffer with OUT_OF_RANGE: broadcast from <slot> is not available, last available: <slot>, and a server with replay switched off answers from_slot is not supported. Either way the stream cannot fill the gap. Then:

  1. Call SubscribeReplayInfo and resubscribe from first_available, or at the tip if replay is off.
  2. Fill the slots between your checkpoint and that point from RPC: getSignaturesForAddress for the accounts you watch, or getBlock for each slot if you index whole blocks.
  3. Run the backfill through the same dedupe as the stream.

Rust: subscribe_with_reconnect

Since yellowstone-grpc-client 14.0.0 (25 September 2026), subscribe and subscribe_with_request no longer reconnect, even with a reconnect config set. If you upgraded from 13.x and relied on that, your stream now simply ends at the first drop. The replacement is subscribe_with_reconnect, a bank-aware reconnecting stream. It reconnects with from_slot itself, from just after the last finalized slot, and when a reconnect cut a bank off halfway it emits ReconnectEvent::DiscardBanks, so you can drop what you built from it before the full replacement arrives.

It has three conditions: the subscription must be at processed, without a startup snapshot, and without a from_slot of your own. The filters are fixed once it starts, so after subscribing you can only send pings. Its default retry budget is small (3 tries from 10 ms, about 70 ms in all), so set a longer one.

Rust
// Cargo.toml: yellowstone-grpc-client = "16.0.1", yellowstone-grpc-proto = "14.0.1",
// tokio = { version = "1", features = ["full"] }, futures = "0.3", anyhow = "1"
use std::{collections::HashMap, time::Duration};

use futures::StreamExt;
use yellowstone_grpc_client::{Backoff, GeyserGrpcClient, ReconnectConfig, ReconnectEvent};
use yellowstone_grpc_proto::geyser::{
    subscribe_update::UpdateOneof, CommitmentLevel, SlotStatus, SubscribeRequest,
    SubscribeRequestFilterSlots, SubscribeRequestFilterTransactions,
};

/// A bank, as the reconnecting stream names it: connection generation, slot, bank_id.
type Bank = (u64, u64, u64);

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let mut client = GeyserGrpcClient::build_from_shared("http://88.216.36.108:10001")?
        .x_token(std::env::var("ALLENHARK_KEY").ok())?
        .max_decoding_message_size(64 * 1024 * 1024)
        // 8 tries from 250 ms, doubling: about a minute before it gives up
        .set_reconnect_config(
            ReconnectConfig::default().with_backoff(Backoff::new(Duration::from_millis(250), 2.0, 8)),
        )
        .connect()
        .await?;

    let mut transactions = HashMap::new();
    transactions.insert(
        "pump".to_string(),
        SubscribeRequestFilterTransactions {
            vote: Some(false),
            failed: Some(false),
            account_include: vec!["6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P".to_string()],
            ..Default::default()
        },
    );
    let mut slots = HashMap::new();
    slots.insert("slots".to_string(), SubscribeRequestFilterSlots::default());
    let request = SubscribeRequest {
        transactions,
        slots,
        commitment: Some(CommitmentLevel::Processed as i32), // required
        ..Default::default()
    };
    let (_sink, mut stream) = client.subscribe_with_reconnect(Some(request)).await?;

    let mut per_bank: HashMap<Bank, usize> = HashMap::new(); // what you build per bank; here a count
    let mut acted: HashMap<Vec<u8>, u64> = HashMap::new(); // signature -> slot, so a replay is not acted on twice

    while let Some(event) = stream.next().await {
        // An error here means recovery gave up: resubscribe and backfill the gap from RPC
        match event? {
            ReconnectEvent::Update { generation, update } => match update.update_oneof {
                Some(UpdateOneof::Transaction(tx)) => {
                    let Some(info) = tx.transaction else { continue };
                    *per_bank.entry((generation, tx.slot, tx.bank_id)).or_default() += 1;
                    if acted.insert(info.signature, tx.slot).is_none() {
                        // First sighting of this signature: act on it here
                    }
                }
                Some(UpdateOneof::Slot(slot)) if slot.status == SlotStatus::SlotFinalized as i32 => {
                    // Keep about a minute of history
                    let keep = slot.slot.saturating_sub(300);
                    per_bank.retain(|bank, _| bank.1 >= keep);
                    acted.retain(|_, at| *at >= keep);
                }
                _ => {}
            },
            ReconnectEvent::DiscardBanks { banks, .. } => {
                // Cut off mid-delivery: drop what was built from these banks. The replacement follows.
                for bank in banks {
                    per_bank.remove(&(bank.generation, bank.slot, bank.bank_id));
                }
            }
        }
    }
    Ok(())
}

DiscardBanks does not mean those blocks lost consensus, only that their delivery was interrupted. The replacement updates come after the event, and transactions you already acted on come again, which the signature check skips. The pruning above is deliberately simple; in production, prune less often than once per slot.

Node.js: a reconnect loop for any provider

This loop runs at finalized, so a slot's status marks it finished. It keeps a checkpoint, resumes from it with from_slot, drops replayed signatures, answers pings, runs a watchdog, and backs off between tries. It uses the same @grpc/grpc-js setup as our gRPC docs.

JavaScript
// resume.mjs: a Yellowstone subscriber that reconnects without gaps.
// npm install @grpc/grpc-js @grpc/proto-loader bs58
// geyser.proto and solana-storage.proto from rpcpool/yellowstone-grpc go in this folder.
import grpc from "@grpc/grpc-js";
import protoLoader from "@grpc/proto-loader";
import bs58 from "bs58";

const ENDPOINT = process.env.GRPC_ENDPOINT ?? "88.216.36.108:10001"; // AllenHark Frankfurt
const WATCH = process.env.WATCH_ACCOUNT ?? "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";

const def = protoLoader.loadSync("geyser.proto", {
  keepCase: true, longs: String, enums: String, defaults: true, oneofs: true,
});
const { geyser } = grpc.loadPackageDefinition(def);
const client = new geyser.Geyser(ENDPOINT, grpc.credentials.createInsecure(), {
  "grpc.max_receive_message_length": 64 * 1024 * 1024,
  "grpc.keepalive_time_ms": 15_000,
  "grpc.keepalive_timeout_ms": 5_000,
  "grpc.keepalive_permit_without_calls": 1,
});

// Retrying these only hammers the server: fix the config instead
const FATAL = new Set([
  grpc.status.PERMISSION_DENIED, grpc.status.UNAUTHENTICATED,
  grpc.status.INVALID_ARGUMENT, grpc.status.UNIMPLEMENTED, grpc.status.FAILED_PRECONDITION,
]);

let checkpoint = null;  // highest finished slot: everything at or below it has arrived
const done = new Map(); // signature -> slot, to skip replayed copies (oldest first)
let attempt = 0;

function onTransaction(signature, slot, tx) {
  // Your code. Runs once per signature.
}

const request = (fromSlot) => ({
  accounts: {},
  slots: { checkpoint: { filter_by_commitment: true } }, // one status per finished slot
  transactions: {
    watch: { vote: false, failed: false, account_include: [WATCH], account_exclude: [], account_required: [] },
  },
  transactions_status: {}, blocks: {}, blocks_meta: {}, entry: {}, accounts_data_slice: [],
  commitment: "FINALIZED",
  ...(fromSlot !== null ? { from_slot: String(fromSlot) } : {}),
});

function connect() {
  const meta = new grpc.Metadata(); // empty from an allowlisted IP
  if (process.env.ALLENHARK_KEY) meta.set("x-token", process.env.ALLENHARK_KEY);
  const stream = client.subscribe(meta);
  let closed = false;
  let watchdog;
  const arm = () => {
    clearTimeout(watchdog);
    watchdog = setTimeout(() => retry("no update for 5 s"), 5_000);
  };

  function retry(why, err) {
    if (closed) return;
    closed = true;
    clearTimeout(watchdog);
    stream.cancel(); // close this stream before the next one opens
    if (err && FATAL.has(err.code)) {
      console.error(`not retrying: ${err.details}`);
      process.exit(1);
    }
    if (err?.code === grpc.status.OUT_OF_RANGE || /from_slot is not supported/.test(err?.details ?? "")) {
      // Older than the replay window, or no replay: resume at the tip, fill the gap from RPC
      console.error(`replay refused (${err.details}); backfill from slot ${checkpoint} by RPC`);
      checkpoint = null;
    }
    const delay = Math.min(30_000, 250 * 2 ** attempt++) * (0.5 + Math.random() / 2);
    console.error(`${why}; reconnecting from ${checkpoint ?? "the tip"} in ${Math.round(delay)} ms`);
    setTimeout(connect, delay);
  }

  stream.on("data", (u) => {
    arm();
    attempt = 0; // a healthy stream resets the backoff
    if (u.update_oneof === "ping") {
      stream.write({ ping: { id: 1 } });
    } else if (u.update_oneof === "transaction") {
      const slot = Number(u.transaction.slot);
      const signature = bs58.encode(u.transaction.transaction.signature);
      if (!done.has(signature)) {
        done.set(signature, slot);
        onTransaction(signature, slot, u.transaction.transaction);
      }
    } else if (u.update_oneof === "slot") {
      const slot = Number(u.slot.slot);
      if (checkpoint === null || slot > checkpoint) checkpoint = slot;
      for (const [signature, at] of done) { // oldest first, so stop at the first one to keep
        if (at >= slot - 1_000) break;
        done.delete(signature);
      }
    }
  });
  stream.on("error", (err) => retry(`stream error ${err.code}: ${err.details}`, err));
  stream.on("end", () => retry("stream ended"));

  // Resume from the checkpoint itself: one slot of overlap, which `done` removes
  stream.write(request(checkpoint));
  arm();
}

connect();

To test it, cut the network for a few seconds, then for a minute, and check that the signatures from the gap arrive once each. Then try a gap longer than your provider's window and check that you get OUT_OF_RANGE and a backfill, not a silent hole.

Checklist

  • Decide whether you need the gap at all. Bots that act on the tip should resume at the tip.
  • Detect drops with a slot watchdog, ping replies and HTTP/2 keepalive.
  • Resume from the last finished slot, and send the full request every time.
  • Make handlers idempotent: signature for transactions; pubkey, slot and write_version for accounts; slot and blockhash for blocks.
  • Clear bank_id state on every reconnect.
  • Retry broken streams with backoff and jitter; stop on access and request errors.
  • Close the old stream before opening a new one.
  • On OUT_OF_RANGE, resubscribe from first_available and backfill the rest from RPC.
  • On yellowstone-grpc-client 14 or later, use subscribe_with_reconnect; plain subscribe no longer reconnects.

AllenHark Yellowstone gRPC

AllenHark Yellowstone gRPC keeps about 48 hours for from_slot replay, so a client can recover from a long outage from the stream itself, not only from a short reconnect. Endpoints are in Frankfurt (88.216.36.108:10001) and Amsterdam (84.32.104.38:10001), plaintext HTTP/2.

  • Authentication: an IP address on your allowlist, or, on new subscriptions, your API key in the x-token header.
  • Alpenglow: streams carry bank_id, so subscribe_with_reconnect and the buffering in our Alpenglow guide work as written.
  • Limits: on gRPC Standard, each connection you buy is one Subscribe stream, so close the old stream before you reconnect. Dedicated gRPC has no rate limits. Plans are on the pricing page.
  • Backfill: AllenHark RPC allows 100 requests per second on the standard plan, and Dedicated RPC has no rate limits.

The Subscribe reference lists every request field, including from_slot and ping.

FAQ

How do I reconnect to Yellowstone gRPC without missing transactions?

Keep the last slot you finished, and when the stream drops, open a new Subscribe with your full request and from_slot set to that slot. Remove duplicates by signature. If the slot is older than the provider's window, resubscribe from first_available and fill the rest from RPC.

How far back can from_slot go?

It depends on the provider, from about 100 slots (about 20 seconds) to about 48 hours. AllenHark keeps about 48 hours. SubscribeReplayInfo returns the oldest slot a server can replay.

Why did my Rust client stop reconnecting after an upgrade?

yellowstone-grpc-client 14.0.0 removed reconnects from subscribe and subscribe_with_request. Use subscribe_with_reconnect, which needs processed commitment, no startup snapshot and no from_slot of your own.

Does a reconnect keep my filters?

No. The server keeps no state between connections, so send every filter again. On an open stream, a request with only ping set leaves the filters unchanged, and any other request replaces them.

Which slot should I resume from?

The last slot you received completely. At finalized, that is the last slot whose status update you received. Resuming one slot early is cheap; resuming one slot late loses data.

Sources

Checked October 29, 2026, except the provider replay windows (28 October 2026, linked in the table).

  • Yellowstone gRPC CHANGELOG: subscribe_with_reconnect and the 14.0.0 change, replay_stored_slots default
  • Yellowstone gRPC server code: replay errors, ping interval, filter replacement
  • yellowstone-grpc-client 16.0.1 source: retryable status codes, default backoff, ReconnectEvent
  • Yellowstone geyser.proto: from_slot, SubscribeReplayInfo, ping and pong
  • Solana Foundation, Alpenglow: finality and bank_id