Solana Tracker LogoSolana Tracker
Swap
Developers
⌘K
Affiliate
All Resources
Yellowstone gRPC Tutorial: Connect, Filter, Reconnect
InfrastructureTypeScriptJune 3, 202613 min readUpdated October 7, 2026Solana Tracker

Yellowstone gRPC Tutorial: Connect, Filter, Reconnect

Yellowstone gRPC tutorial in TypeScript: authenticate with x-token, subscribe to slots and transactions, answer pings, and reconnect without gaps.

  • yellowstone-grpc,
  • geyser,
  • streaming,
  • typescript,
  • solana-rpc
Yellowstone gRPC›Solana RPC›Dedicated Nodes›

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.

OptionWhat you receiveFilteringYou maintainGood fit
Yellowstone gRPCRaw transactions, accounts, slots, blocksServer-side by account, program, commitmentDecoders, reconnect, gap recoveryBots, indexers, custom program parsers
Solana RPC WebSocket (logsSubscribe, accountSubscribe)Notifications per subscriptionPer address or programReconnect, refetching full transactionsA few accounts, light workloads
Datastream (Data API WebSocket)Indexed tokens, pools, trades, pricesBy room (token, pool, wallet)Almost nothing; it is already parsedDashboards, alerts, trading UIs
RPC polling (getSignaturesForAddress)Historical transactionsPer addressCursors, rate limitsBackfills 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.

RegionEndpoint
EUhttps://grpc.solanatracker.io
UShttps://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 (grpcMaxDecodingMessageSize in 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.

LevelWhen an update arrivesCan it roll backUse it for
PROCESSEDAs soon as the node executes itYes, the slot can be skipped or forked awayLowest-latency signals you can retract (snipers, mempool-style alerts)
CONFIRMEDAfter a supermajority of stake voted on the blockRarelyTrade feeds, dashboards, most bots
FINALIZEDAfter the block is rootedNoAccounting, 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:

  1. Subscription pings. The server periodically sends an update with ping set. Reply by writing a request that contains ping: { id: 1 }; the server answers with pong. The Solana Tracker docs and the companion project send it with empty filter maps. Transport-level HTTP/2 keepalive (the grpcHttp2KeepAliveInterval channel options) is a separate mechanism and does not replace this reply.
  2. Reconnect. An ordinary subscribe() stream does not reconnect on its own. When it emits error, end or close, 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.
  3. Gap recovery. A reconnect leaves a hole. Block metadata makes it visible: each blockMeta carries parentSlot, so a block whose parent is not the last block you saw means missed blocks or a fork switch. Backfill that range with getBlock over 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 data handler 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 accountRequired where 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
View source on GitHub›

Runnable Node.js project — clone from GitHub, add keys to .env, then npm start. gRPC examples use native dependencies — run locally with Node.js.

Related Guides

Yellowstone gRPC Examples: 18 TypeScript Recipes for Solana
Infrastructure

Yellowstone gRPC Examples: 18 TypeScript Recipes for Solana

Read more
Yellowstone gRPC Account Subscribe: Stream Token Balances
Infrastructure

Yellowstone gRPC Account Subscribe: Stream Token Balances

Read more
Reduce Solana RPC Latency: Measure, Then Fix
Infrastructure

Reduce Solana RPC Latency: Measure, Then Fix

Read more

Products

  • Data API
  • Pump.fun API
  • Solana RPC
  • Dedicated Nodes
  • Yellowstone gRPC
  • Raptor Swap API
  • Enterprise

Trading

  • Swap
  • Latest Tokens
  • Trending
  • Top Gainers
  • Memescope
  • Whale Watch
  • KOL Tracker

Tools

  • Wallet Tracker
  • Rugcheck
  • PnL Leaderboard
  • KOLScan
  • Axiom Leaderboard
  • Photon Leaderboard
  • Bloom Leaderboard
  • FOMO Leaderboard
  • GMGN Leaderboard
  • Pump.fun App Leaderboard
  • Terminal Leaderboard
  • Platform Compare
  • My Positions
  • Teams

Resources

  • Developer Guides
  • Blog
  • Documentation
  • API Reference
  • Status
  • Affiliate Program — 25% recurring, uncapped
Solana TrackerSolana Tracker© 2026
Terms of ServicePrivacy PolicyContact