Replay
The Kaldera replay window, the fromSlot parameter, and resuming a stream after a disconnect.
Kaldera keeps recent history, so a reconnecting client can replay what it missed.
Replay window
Kaldera answers fromSlot from two places.
| Source | Covers |
|---|---|
| The recent-slot buffer in your region | The last 1,000 slots or so: about 4 to 5 minutes of chain time at current slot times |
| The replay store | Up to 24 hours, on Blade Runner and on Nexus |
Not every region keeps its own replay store. A region without one is served from a region that has one, so the replayed part of the stream carries extra round-trip latency until it catches up. If no store is reachable, the region falls back to its recent-slot buffer.
24 hours is a span, not a promise
The replay store spans up to 24 hours, but a break in the feed or a restart leaves a hole inside that span. A hard-coded window is unsafe: the server reports the oldest slot it can serve, and nothing earlier can be requested.
The available span
SubscribeReplayInfo is a single request and response, not a stream. It uses the same endpoint and API key as Subscribe, and returns firstAvailable: the oldest slot that region can replay from. On reconnect, the slot to use is the larger of your own last slot and firstAvailable.
const client = new Client(ENDPOINT, process.env.CK_API_KEY, undefined);
await client.connect();
const info = await client.subscribeReplayInfo(); // { firstAvailable?: string }
const oldest = info.firstAvailable === undefined ? undefined : BigInt(info.firstAvailable);
// Never ask for a slot the server cannot serve
const from = lastSlot !== undefined && oldest !== undefined && lastSlot < oldest ? oldest : lastSlot;Resuming a stream
fromSlot on a SubscribeRequest starts the stream at that slot. Kaldera first sends the matching updates from there, then continues with live data on the same stream. If the server loses its own source in the middle of a replay, it resumes at the last slot it sent you, not at your original fromSlot, so you do not receive the replay twice.
- Record the highest
slotyou have handled. - Resume from that slot, not the next one. A slot's updates can be split across the moment you disconnected.
- Deduplicate by a stable key. Replay can resend updates you already handled.
| Update | Key |
|---|---|
| Transaction | Signature |
| Account | Pubkey, slot and writeVersion |
| Slot | Slot and status |
What replay covers
| Updates | Replay |
|---|---|
| Transactions and transaction status | Verified against block history, with nothing missing and nothing repeated |
| Slot status | Verified |
| Block meta | Verified |
| Accounts and entries | Not verified. Replay is not a source of record for these. |
Replay is not available on Turbo Kaldera: a request that carries fromSlot is rejected with INVALID_ARGUMENT. Standard Kaldera covers the gap instead.
Throughput of a deep replay
A replay runs as fast as your client reads, far faster than live traffic. A consumer that cannot keep up overflows its buffer and loses the stream.
- A general-purpose command-line client drains roughly 440 messages a second. That is not enough for a transaction filter over a few thousand slots.
- The socket belongs on its own task or thread, with decoding and business logic elsewhere.
- The narrowest filter that closes the gap replays faster than a full production filter.
- Hours of history rather than minutes are paged: a few hundred slots, a checkpoint, then the next range.
A stream that survives disconnects
This subscriber reconnects with exponential backoff, resumes from the last slot it saw, and skips duplicates.
import Client, {
CommitmentLevel,
type SubscribeRequest,
type SubscribeUpdate,
} from "@triton-one/yellowstone-grpc";
import bs58 from "bs58";
const ENDPOINT = "https://kaldera-newyork.constant-k.com";
const request: SubscribeRequest = {
commitment: CommitmentLevel.CONFIRMED,
transactions: {
pumpfun: {
vote: false,
accountInclude: ["6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"],
accountExclude: [],
accountRequired: [],
},
},
accounts: {},
slots: {},
transactionsStatus: {},
blocks: {},
blocksMeta: {},
entry: {},
blockFooter: {},
accountsDataSlice: [],
};
let lastSlot: bigint | undefined; // highest slot seen so far
const seen = new Map<string, bigint>(); // signature -> slot, to drop replayed duplicates
function handleTransaction(signature: string, slot: bigint) {
console.log(slot, signature);
}
async function runStream(): Promise<void> {
const client = new Client(ENDPOINT, process.env.CK_API_KEY, undefined);
await client.connect();
// Resume from the last slot you saw. fromSlot is a decimal string.
const stream = await client.subscribe(
lastSlot === undefined ? request : { ...request, fromSlot: lastSlot.toString() },
);
await new Promise<void>((resolve) => {
stream.on("data", (update: SubscribeUpdate) => {
const tx = update.transaction;
if (!tx?.transaction) return;
const slot = BigInt(tx.slot);
const signature = bs58.encode(tx.transaction.signature);
if (seen.has(signature)) return; // already handled before the reconnect
seen.set(signature, slot);
handleTransaction(signature, slot);
if (lastSlot === undefined || slot > lastSlot) lastSlot = slot;
// Keep memory bounded: forget signatures from old slots
if (seen.size > 200_000) {
for (const [sig, s] of seen) if (s + 1_000n < slot) seen.delete(sig);
}
});
stream.on("error", (err) => console.error("stream error:", err.message));
stream.on("close", () => resolve());
});
}
// Reconnect forever, with capped exponential backoff
for (let attempt = 0; ; attempt++) {
const started = Date.now();
await runStream().catch((err) => console.error("connect failed:", err.message));
if (Date.now() - started > 60_000) attempt = 0; // it ran for a while: reset the backoff
const delay = Math.min(500 * 2 ** attempt, 30_000);
await new Promise((resolve) => setTimeout(resolve, delay));
}Built-in reconnect in the TypeScript client
@triton-one/yellowstone-grpc 8.0.0 and later have a built-in reconnect layer, subscribeWithReconnect(). Plain subscribe() never reconnects.
import Client, { CommitmentLevel, type ReconnectEvent } from "@triton-one/yellowstone-grpc";
const client = new Client(
"https://kaldera-newyork.constant-k.com",
process.env.CK_API_KEY,
undefined,
{ backoff: { initialIntervalMs: 100, multiplier: 2, maxRetries: 10 } }, // backoff only
);
await client.connect();
// processed commitment, no fromSlot: the layer picks the replay point
const stream = await client.subscribeWithReconnect({ ...request, commitment: CommitmentLevel.PROCESSED });
stream.on("data", (event: ReconnectEvent) => {
if (event.type === "Update") {
handleUpdate(event.update);
} else {
// DiscardBanks: undo what you stored for these banks; replacement updates follow
for (const bank of event.banks) dropBank(bank.slot, bank.bankId);
}
});
stream.on("error", (err) => console.error("recovery failed:", err.message));| Rule | Detail |
|---|---|
| Request | processed commitment, no fromSlot, no startup snapshot |
| Fourth constructor argument | backoff only. connect() rejects enabled, policy and slotRetention. |
| Resume point | After the last finalized slot |
| Events | Update (generation, update) or DiscardBanks (banks to undo, then replacement updates) |
| Extra filters | A slots and a blocksMeta filter, both named __autoreconnect. They count toward your filters-per-stream limit. |
| End of stream | OUT_OF_RANGE or exhausted retries end the stream with an error |
| Memory | Recovery buffers updates with no size cap while it waits for finalized slots |
The layer deduplicates its own backfill, not your business logic. Your own idempotency still matters where a repeated update would cost money.
If the slot is too old
A fromSlot outside the window cannot be served. The stream fails with gRPC status OUT_OF_RANGE (11) and a message that names the oldest slot you can ask for:
broadcast from 447000000 is not available, last available: 447700000That second number is the oldest available slot, not the newest. It is the same value SubscribeReplayInfo returns, and a client that retries the same too-old fromSlot gets the same error every time.
- Resubscribe from the oldest available slot, or without
fromSlotto go straight to live data. - Fill the gap from Solana RPC if you need it, for example with
getSignaturesForAddressandgetTransactionfor the accounts you track.
Other ways to avoid gaps
- Redundant streams. The same filters in more than one region, or twice in one region, with deduplication.
- Consumer speed. On Blade Runner, a client that falls far behind loses the oldest queued updates. See Plans & Limits.