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.
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
Subscribecall needs the full request again: every filter, the commitment level andfrom_slot. - The window is the provider's buffer. A slot older than what the server still holds is refused.
SubscribeReplayInforeturnsfirst_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.
| Provider | Replay window | In time |
|---|---|---|
| AllenHark | About 48 hours | About 48 hours |
| Helius LaserStream | About 48 hours (fromSlot); older than about 20 minutes returns finalized data only | About 48 hours |
| Alchemy | Last 6,000 slots | About 20 minutes |
| QuickNode | "About the last 11 minutes of slots" | About 11 minutes |
| Shyft | 150 slots | About 30 seconds |
| Chainstack Global Node | About 100 slots; larger on Dedicated Nodes | About 20 seconds |
| Triton Dragon's Mouth | Not published; Fumarole keeps up to 4 days | Not published |
| Self-hosted Yellowstone plugin | replay_stored_slots, 150 by default since April 2026 | About 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
blockhashfrom the block meta, not bybank_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 status | Retry? | Usual meaning |
|---|---|---|
UNAVAILABLE, INTERNAL, UNKNOWN, CANCELLED, ABORTED, DEADLINE_EXCEEDED, DATA_LOSS | Yes | The connection or the server broke. INTERNAL: lagged to send an update means your client read too slowly. |
RESOURCE_EXHAUSTED | Yes, once the old stream is closed | On AllenHark: more streams from one address than your plan allows. |
OUT_OF_RANGE | Not with the same from_slot | The slot is older than the replay window. |
PERMISSION_DENIED, UNAUTHENTICATED | No | On 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_PRECONDITION | No | Fix 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:
- Call
SubscribeReplayInfoand resubscribe fromfirst_available, or at the tip if replay is off. - Fill the slots between your checkpoint and that point from RPC:
getSignaturesForAddressfor the accounts you watch, orgetBlockfor each slot if you index whole blocks. - 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.
// 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.
// 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_versionfor accounts; slot andblockhashfor blocks. - Clear
bank_idstate 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 fromfirst_availableand backfill the rest from RPC. - On
yellowstone-grpc-client14 or later, usesubscribe_with_reconnect; plainsubscribeno 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-tokenheader. - Alpenglow: streams carry
bank_id, sosubscribe_with_reconnectand the buffering in our Alpenglow guide work as written. - Limits: on gRPC Standard, each connection you buy is one
Subscribestream, 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.
Related reading
- Why your Yellowstone gRPC stream lags or misses events
- Alpenglow: what Solana RPC and gRPC users need to change
- Best Yellowstone gRPC providers in 2026
Sources
Checked October 29, 2026, except the provider replay windows (28 October 2026, linked in the table).
- Yellowstone gRPC CHANGELOG:
subscribe_with_reconnectand the 14.0.0 change,replay_stored_slotsdefault - Yellowstone gRPC server code: replay errors, ping interval, filter replacement
yellowstone-grpc-client16.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
