This Yellowstone gRPC tutorial takes you from an endpoint and token to a stream that survives disconnects: authenticate with x-token, subscribe to slots and block metadata, add a filtered transaction subscription, answer server pings, and reconnect with backoff while detecting gaps. Every example uses @triton-one/yellowstone-grpc 8.x in TypeScript against Solana Tracker's EU or US endpoint.
Yellowstone gRPC is the Geyser streaming interface for Solana: one persistent HTTP/2 connection pushes accounts, transactions, slots and blocks as the validator processes them, filtered on the server. It is available as a standalone Yellowstone gRPC plan and is included on Business and Professional Solana RPC plans and on every dedicated node.
When to use gRPC instead of WebSockets or polling
gRPC gives you raw chain data with server-side filters, and you own the decoding, ordering and recovery. Use it for instruction-level detail or the earliest view of a program's activity; use an indexed feed when you only need prices, trades or token objects.
| Option | What you receive | Filtering | You maintain | Good fit |
|---|---|---|---|---|
| Yellowstone gRPC | Raw transactions, accounts, slots, blocks | Server-side by account, program, commitment | Decoders, reconnect, gap recovery | Bots, indexers, custom program parsers |
Solana RPC WebSocket (logsSubscribe, accountSubscribe) | Notifications per subscription | Per address or program | Reconnect, refetching full transactions | A few accounts, light workloads |
| Datastream (Data API WebSocket) | Indexed tokens, pools, trades, prices | By room (token, pool, wallet) | Almost nothing; it is already parsed | Dashboards, alerts, trading UIs |
RPC polling (getSignaturesForAddress) | Historical transactions | Per address | Cursors, rate limits | Backfills and reconciliation |
If you are building on top of a specific program, the follow-up guides show the decoding half: Pump.fun gRPC create parsing, Raydium AMM v4 swap parsing and Meteora DLMM swap parsing. To follow state rather than transactions, such as a wallet's token balances or a pool's reserves, see the Yellowstone gRPC account subscribe guide.
Connect and authenticate
Solana Tracker runs two endpoints. Pick the one closest to your consumer, and measure from that host.
| Region | Endpoint |
|---|---|
| EU | https://grpc.solanatracker.io |
| US | https://grpc-us.solanatracker.io |
The token comes from the Yellowstone gRPC dashboard and is sent as the x-token header through the client constructor. It is not the Data API key; the Data API uses x-api-key and a different host. Keep both values in environment variables:
# Yellowstone gRPC endpoint: https://grpc.solanatracker.io (EU) or https://grpc-us.solanatracker.io (US)
YELLOWSTONE_GRPC_ENDPOINT=
# x-token from https://www.solanatracker.io/account/yellowstone-grpc
YELLOWSTONE_GRPC_TOKEN=
# Program or account to filter transactions on (default: the Pump.fun program)
WATCH_ADDRESS=6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P
# processed, confirmed or finalized
COMMITMENT=confirmed
Before streaming anything, check the credentials with a unary call. A version response proves the endpoint and token are accepted:
import Client from "@triton-one/yellowstone-grpc";
const client = new Client(process.env.YELLOWSTONE_GRPC_ENDPOINT ?? "", process.env.YELLOWSTONE_GRPC_TOKEN, undefined);
await client.connect();
console.log(await client.getVersion()); // { version: "..." }
When it fails, the error tells you where to look:
- Unauthenticated: the token is missing, has trailing whitespace, or belongs to an inactive subscription.
- Unavailable or timeout: wrong URL, or outbound TLS/HTTP2 is blocked. Try the other region.
- Resource exhausted: read the detail. A connection allowance and a message-size limit are different problems, and raising the client's receive limit (
grpcMaxDecodingMessageSizein the channel options) does not fix a quota error. - Connected but silent: subscribe to
blocksMeta: { blocks: {} }alone. If blocks arrive, your other filters match nothing; reapply them one at a time.
Yellowstone gRPC tutorial: your first subscription
Version 8 of the client requires every filter map on a SubscribeRequest, even the ones you leave empty: accounts, slots, transactions, transactionsStatus, blocks, blocksMeta, entry, blockFooter (new in 8.x) and accountsDataSlice. Start with slots and block metadata, which are small and confirm the stream is alive, then add a transaction filter for the address you care about.
Set up a project and install the client:
npm init -y && npm pkg set type=module
npm install @triton-one/yellowstone-grpc@^8.0.0 bs58@^6.0.0
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts
Save this as index.ts. It validates input, subscribes, answers pings, reconnects with capped exponential backoff and jitter, flags gaps between blocks, and exits cleanly on Ctrl+C.
import Client, { CommitmentLevel, SlotStatus, type SubscribeRequest, type SubscribeUpdate } from "@triton-one/yellowstone-grpc";
import bs58 from "bs58";
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
console.error(`Missing ${name} in .env`);
process.exit(1);
}
return value;
}
const endpoint = required("YELLOWSTONE_GRPC_ENDPOINT");
const token = required("YELLOWSTONE_GRPC_TOKEN");
const watch = process.env.WATCH_ADDRESS?.trim() || "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";
if (!isAddress(watch)) {
console.error(`WATCH_ADDRESS is not a base58 Solana address: ${watch}`);
process.exit(1);
}
const LEVELS: Record<string, CommitmentLevel> = {
processed: CommitmentLevel.PROCESSED,
confirmed: CommitmentLevel.CONFIRMED,
finalized: CommitmentLevel.FINALIZED,
};
const commitment = LEVELS[(process.env.COMMITMENT?.trim() || "confirmed").toLowerCase()];
if (commitment === undefined) {
console.error("COMMITMENT must be processed, confirmed or finalized");
process.exit(1);
}
// Every map is required in 8.x, even when empty.
function baseRequest(): SubscribeRequest {
return {
accounts: {}, slots: {}, transactions: {}, transactionsStatus: {}, blocks: {},
blocksMeta: {}, entry: {}, blockFooter: {}, accountsDataSlice: [], commitment,
};
}
const subscription: SubscribeRequest = {
...baseRequest(),
slots: { slots: { filterByCommitment: true } },
blocksMeta: { blocks: {} },
transactions: {
watched: { accountInclude: [watch], accountExclude: [], accountRequired: [], vote: false, failed: false },
},
};
let lastBlock: bigint | undefined;
let stopping = false;
let active: Awaited<ReturnType<Client["subscribe"]>> | undefined;
function handle(update: SubscribeUpdate): void {
if (update.slot?.status === SlotStatus.SLOT_DEAD) {
console.warn(`slot ${update.slot.slot} marked dead`);
} else if (update.transaction?.transaction) {
// Signatures arrive as bytes; slots are u64 strings.
console.log(`tx ${update.transaction.slot} ${bs58.encode(update.transaction.transaction.signature)}`);
} else if (update.blockMeta) {
const slot = BigInt(update.blockMeta.slot);
const parent = BigInt(update.blockMeta.parentSlot);
if (lastBlock !== undefined && parent !== lastBlock && slot > lastBlock) {
console.warn(`gap: block ${slot} has parent ${parent}, last seen ${lastBlock}; backfill over RPC`);
}
if (lastBlock === undefined || slot > lastBlock) lastBlock = slot;
console.log(`block ${slot} txs=${update.blockMeta.executedTransactionCount}`);
}
}
async function streamOnce(onHealthy: () => void): Promise<void> {
const client = new Client(endpoint, token, undefined);
await client.connect();
const stream = await client.subscribe();
active = stream;
console.log("[grpc] connected");
await new Promise<void>((resolve, reject) => {
const closed = () => (stopping ? resolve() : reject(new Error("stream closed by server")));
stream.on("data", (update: SubscribeUpdate) => {
onHealthy();
if (update.ping) {
// The server pings idle streams and closes them if nobody answers.
stream.write({ ...baseRequest(), ping: { id: 1 } });
return;
}
if (!update.pong) handle(update);
});
stream.on("error", reject);
stream.on("end", closed);
stream.on("close", closed);
stream.write(subscription);
});
}
process.on("SIGINT", () => {
stopping = true;
active?.end();
active?.destroy();
console.log(`\nStopped at block ${lastBlock ?? "n/a"}`);
process.exit(0);
});
let attempt = 0;
while (!stopping) {
try {
await streamOnce(() => (attempt = 0));
} catch (error) {
if (stopping) break;
attempt++;
const delay = Math.min(30_000, 500 * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);
console.warn(`[grpc] ${error instanceof Error ? error.message : error}; reconnecting in ${Math.round(delay)} ms`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
function isAddress(value: string): boolean {
try {
return bs58.decode(value).length === 32;
} catch {
return false;
}
}
The transaction filter fields combine like this: a transaction matches when it touches any key in accountInclude, all keys in accountRequired, and none in accountExclude. vote: false drops vote transactions and failed: false drops failed ones; omit a flag to receive both. Several named filters in one map are ORed, and each update's filters array tells you which names matched.
Commitment levels compared
Set commitment on the request with the CommitmentLevel enum. It applies to the whole subscription, and filterByCommitment: true on the slot filter limits slot updates to that level instead of every status change.
| Level | When an update arrives | Can it roll back | Use it for |
|---|---|---|---|
PROCESSED | As soon as the node executes it | Yes, the slot can be skipped or forked away | Lowest-latency signals you can retract (snipers, mempool-style alerts) |
CONFIRMED | After a supermajority of stake voted on the block | Rarely | Trade feeds, dashboards, most bots |
FINALIZED | After the block is rooted | No | Accounting, balances, anything you cannot undo |
Use the same commitment for RPC backfills, or latency between levels looks like missing data.
Pings, reconnects and gap recovery
Three different mechanisms keep a stream healthy, and all three matter:
- Subscription pings. The server periodically sends an update with
pingset. Reply by writing a request that containsping: { id: 1 }; the server answers withpong. The Solana Tracker docs and the companion project send it with empty filter maps. Transport-level HTTP/2 keepalive (thegrpcHttp2KeepAliveIntervalchannel options) is a separate mechanism and does not replace this reply. - Reconnect. An ordinary
subscribe()stream does not reconnect on its own. When it emitserror,endorclose, create a new client, subscribe, and write the full request again. Back off exponentially with jitter and reset the counter once data flows, as the loop above does. - Gap recovery. A reconnect leaves a hole. Block metadata makes it visible: each
blockMetacarriesparentSlot, so a block whose parent is not the last block you saw means missed blocks or a fork switch. Backfill that range withgetBlockover Solana RPC, deduplicate by signature, and only then advance your persisted cursor.
Skipped slots are normal (some slots produce no block), so compare parent links rather than counting slot numbers.
The request also has a fromSlot field and the client exposes subscribeReplayInfo(), which reports the oldest replayable slot as firstAvailable. Treat replay as optional: use it only when that call confirms retention on your endpoint, keep the RPC backfill path, and deduplicate whatever replays.
Changing filters: each request replaces the last
A subscription is a bidirectional stream. Every SubscribeRequest you write after the first replaces the entire filter set; it does not merge. To add an address, write the old filters plus the new one; to drop one, write the set without it. Keep the current request in one variable so a reconnect resends the latest version.
Decoding the update payload
Updates arrive as a SubscribeUpdate with one populated field. Two encoding rules cover most bugs: public keys, signatures and instruction data are Uint8Array (encode with bs58 for display), and protobuf 64-bit integers (slots, fees, raw token amounts) are strings. Keep them as strings or BigInt until you format them.
type TransactionUpdate = {
filters: string[]; // names of the filters that matched
transaction?: {
slot: string; // u64 as string
transaction?: {
signature: Uint8Array;
isVote: boolean;
index: string;
transaction?: {
message?: {
accountKeys: Uint8Array[]; // static keys only
instructions: { programIdIndex: number; accounts: Uint8Array; data: Uint8Array }[];
versioned: boolean;
};
};
meta?: {
err?: { err: Uint8Array }; // present when the transaction failed
fee: string;
innerInstructions: { index: number; instructions: { programIdIndex: number; accounts: Uint8Array; data: Uint8Array; stackHeight?: number }[] }[];
preTokenBalances: { accountIndex: number; mint: string; owner: string; uiTokenAmount?: { amount: string; decimals: number } }[];
postTokenBalances: { accountIndex: number; mint: string; owner: string; uiTokenAmount?: { amount: string; decimals: number } }[];
loadedWritableAddresses: Uint8Array[];
loadedReadonlyAddresses: Uint8Array[];
};
};
};
};
Instruction account indexes point into the full key list: static accountKeys, then loadedWritableAddresses, then loadedReadonlyAddresses. Skip the loaded addresses and every versioned transaction that uses a lookup table resolves the wrong accounts. Block metadata is simpler: slot, blockhash, parentSlot, parentBlockhash, executedTransactionCount, blockHeight and blockTime.timestamp, with the numbers again as strings.
subscribeWithReconnect: the built-in alternative
Version 8 adds subscribeWithReconnect(). Instead of plain updates it emits ReconnectEvent objects: Update events carry a SubscribeUpdate plus a connection generation, and DiscardBanks events list banks you must remove after an interrupted delivery, with a replacement replay range and finalized slot winners. The fourth constructor argument configures its backoff (initialIntervalMs, multiplier, maxRetries).
Its constraints decide whether it fits:
- Processed commitment only.
- No startup snapshots and no initial
fromSlot. - It waits for finalized evidence before emitting discards, and buffers updates without a size cap while it waits.
- A failed replay or exhausted retries end the stream with an error, so you still need an outer restart.
It suits processed-level state keyed by (generation, slot, bankId). For confirmed-level consumers, a plain subscribe() loop with parent-link gap checks is simpler. If you are upgrading from 7.x, remove enabled, policy and slotRetention from the fourth constructor argument; 8.x rejects them.
Production pitfalls
- One slow handler stalls the stream. Parse cheaply in the
datahandler and push heavy work to a bounded queue. Track queue depth and the oldest pending slot. - Broad filters. A program filter matches every transaction that lists the program key, including ones that never invoke it. Narrow with
accountRequiredwhere you can, and decode before counting. - Persisting too early. Save your cursor only after the downstream write succeeds, or a crash skips data.
- Logging secrets. Never print the token. Rotate it in the dashboard if it leaks.
- Duplicate deliveries. Reconnects and backfills overlap. Key on signature (plus instruction position when you emit per instruction).
- Message size. Full blocks can exceed the receive limit. Prefer block metadata or filtered transactions.
FAQ
Which endpoint should I use, EU or US?
The one with the lowest measured delivery time from your consumer's host. Both accept the same token, so keep the other as a failover.
Why does my stream close after a minute of no traffic?
You are not answering subscription pings. When an update has ping set, write a request with ping: { id: 1 }. Transport keepalive settings do not count as a reply.
Can I change filters without reconnecting?
Yes. Write a new SubscribeRequest on the same stream. It replaces the whole filter set, so include every filter you still want.
How do I avoid missing data during a reconnect?
Track block parent links from blockMeta, record the last fully processed slot, and backfill the missing range over RPC after reconnecting. Use fromSlot replay only if subscribeReplayInfo() confirms retention on your endpoint.
Does @triton-one/yellowstone-grpc reconnect automatically?
Only subscribeWithReconnect(), which is processed-only and has the constraints listed above. Ordinary subscribe() streams end on error, and your code must reconnect.
What does Yellowstone gRPC cost on Solana Tracker?
The standalone plan is €200 per month flat with 25 concurrent connections across EU and US. gRPC is also included on Business and Professional shared RPC plans and on every dedicated node. Current details are on the Yellowstone gRPC page.
References
- Yellowstone gRPC overview
- Yellowstone gRPC quickstart
- Authenticate a Yellowstone gRPC client
- Slot and block monitoring
- Transaction monitoring
- Reconnects and stream load
- @triton-one/yellowstone-grpc on npm
- bs58 on npm
Companion project
The full example lives at solanatracker/examples/07-yellowstone-grpc-setup. It runs the same subscription, prints one row per block with the matched transaction count and a sample signature, reports gaps and dead slots, and prints a summary on Ctrl+C.
cp .env.example .env && npm install && npm start