Solana processes over 4,000 transactions per second. If you are building a token indexer, NFT activity tracker, or DeFi protocol monitor, polling RPC endpoints will never keep up. The Yellowstone Geyser plugin interface solves this by letting you tap directly into the validator data pipeline -- receiving account updates, transaction notifications, and slot changes the moment they happen, with zero network overhead.
This guide walks you through building a custom Geyser plugin from scratch in Rust, deploying it on a validator, and using it to power real-time indexing pipelines that outperform any RPC-based approach.
This is a real-time architecture — a Geyser plugin only sees data from the moment it's deployed. If you need to query what happened before that (backfills, research, full-history trackers), see the Solana historical data access guide.
Why Geyser plugins beat RPC polling
Traditional Solana data pipelines rely on getBlock, getTransaction, or WebSocket subscriptions through JSON-RPC. These approaches share fundamental limitations:
- Latency: RPC requests add network round-trips and queue behind other clients
- Completeness: WebSocket subscriptions can silently drop messages under load
- Cost: High-frequency polling burns through rate limits and inflates infrastructure bills
- Filtering: You download entire blocks and filter client-side, wasting bandwidth
Geyser plugins run inside the validator process itself. When the runtime finishes executing a transaction, your plugin receives the results through a direct function call -- no network, no serialization overhead, no dropped messages. You filter at the source and only forward what matters to your downstream systems.
Hosted providers like Helius and Triton offer managed Geyser-powered gRPC streams that abstract this complexity. But when you need full control over filtering logic, data transformation, or want to avoid per-request pricing, building your own plugin is the way to go.
How the Geyser plugin interface works
The Solana validator exposes a plugin interface defined in the solana-geyser-plugin-interface crate. Your plugin implements the GeyserPlugin trait, which the validator loads as a shared library (.so file) at startup.
The trait defines four core callbacks:
| Callback | Fires when | Typical use |
|---|
update_account | An account data changes | Token balance tracking, program state indexing |
notify_transaction | A transaction is confirmed | Trade parsing, transfer monitoring |
notify_block_metadata | A block is completed | Slot-level analytics, block production metrics |
update_slot_status | Slot status changes | Chain progress monitoring |
Each callback runs synchronously in the validator replay pipeline. Your plugin must return quickly -- heavy processing should be offloaded to a background thread or external queue.
Project setup
Start by creating a new Rust library with the correct crate type. Geyser plugins compile to a C-compatible shared library that the validator loads at runtime.
// Cargo.toml
[package]
name = "my-geyser-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
solana-geyser-plugin-interface = "2.2"
solana-sdk = "2.2"
solana-transaction-status = "2.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bs58 = "0.5"
crossbeam-channel = "0.5"
log = "0.4"
Pin your solana-* crate versions to match the validator version you are targeting. A version mismatch will cause the plugin to fail at load time.
Implementing the GeyserPlugin trait
Here is a minimal but functional plugin skeleton that filters account updates for a specific program and forwards them to a background processing thread:
use solana_geyser_plugin_interface::geyser_plugin_interface::{
GeyserPlugin, GeyserPluginError, ReplicaAccountInfoVersions,
ReplicaTransactionInfoVersions, ReplicaBlockInfoVersions,
Result as PluginResult, SlotStatus,
};
use crossbeam_channel::{bounded, Sender};
use std::thread;
pub struct MyGeyserPlugin {
sender: Option<Sender<AccountEvent>>,
target_program: [u8; 32],
}
struct AccountEvent {
pubkey: Vec<u8>,
data: Vec<u8>,
slot: u64,
lamports: u64,
}
impl GeyserPlugin for MyGeyserPlugin {
fn name(&self) -> &'static str {
"MyGeyserPlugin"
}
fn on_load(
&mut self,
config_file: &str,
_is_reload: bool,
) -> PluginResult<()> {
let config: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(config_file)
.map_err(|e| GeyserPluginError::ConfigFileReadError {
msg: e.to_string(),
})?,
)
.map_err(|e| GeyserPluginError::ConfigFileReadError {
msg: e.to_string(),
})?;
let program_id = config["target_program"]
.as_str()
.expect("target_program required in config");
let decoded = bs58::decode(program_id)
.into_vec()
.expect("invalid base58 program ID");
self.target_program.copy_from_slice(&decoded);
let (sender, receiver) = bounded::<AccountEvent>(10_000);
self.sender = Some(sender);
thread::spawn(move || {
while let Ok(event) = receiver.recv() {
log::info!(
"Account {} updated at slot {}",
bs58::encode(&event.pubkey).into_string(),
event.slot
);
}
});
Ok(())
}
fn update_account(
&self,
account: ReplicaAccountInfoVersions,
slot: u64,
_is_startup: bool,
) -> PluginResult<()> {
let info = match account {
ReplicaAccountInfoVersions::V0_0_3(info) => info,
_ => return Ok(()),
};
if info.owner != self.target_program.as_slice() {
return Ok(());
}
if let Some(sender) = &self.sender {
let _ = sender.try_send(AccountEvent {
pubkey: info.pubkey.to_vec(),
data: info.data.to_vec(),
slot,
lamports: info.lamports,
});
}
Ok(())
}
fn notify_transaction(
&self,
_transaction: ReplicaTransactionInfoVersions,
_slot: u64,
) -> PluginResult<()> {
Ok(())
}
fn notify_block_metadata(
&self,
_blockinfo: ReplicaBlockInfoVersions,
) -> PluginResult<()> {
Ok(())
}
fn update_slot_status(
&self,
_slot: u64,
_parent: Option<u64>,
_status: SlotStatus,
) -> PluginResult<()> {
Ok(())
}
fn account_data_notifications_enabled(&self) -> bool {
true
}
fn transaction_notifications_enabled(&self) -> bool {
false
}
}
The key design decisions here: filter by owner inside update_account to discard irrelevant accounts immediately, use a bounded channel to decouple the validator hot path from your processing logic, and keep the callback itself allocation-light.
Use case: custom token indexer
To build a token balance indexer, set target_program to the SPL Token program (TokenkFGee...) and parse the account data in your background thread:
fn parse_token_account(data: &[u8]) -> Option<TokenBalance> {
if data.len() != 165 {
return None; // SPL token accounts are exactly 165 bytes
}
let mint = &data[0..32];
let owner = &data[32..64];
let amount = u64::from_le_bytes(
data[64..72].try_into().ok()?,
);
Some(TokenBalance {
mint: bs58::encode(mint).into_string(),
owner: bs58::encode(owner).into_string(),
amount,
})
}
This gives you every token balance change across the entire chain in real-time -- no missed transfers, no polling delays. Pair it with a PostgreSQL sink and you have a complete token indexer running at validator speed.
Use case: NFT activity tracker
For NFT monitoring, listen to transactions instead of accounts. Enable transaction notifications and filter for Metaplex or Tensor program IDs:
fn notify_transaction(
&self,
transaction: ReplicaTransactionInfoVersions,
slot: u64,
) -> PluginResult<()> {
let info = match transaction {
ReplicaTransactionInfoVersions::V0_0_2(info) => info,
_ => return Ok(()),
};
if info.is_vote {
return Ok(()); // Skip vote transactions (90%+ of traffic)
}
let account_keys = &info.transaction
.message()
.account_keys();
let involves_nft_program = account_keys.iter().any(|key| {
key.as_ref() == METAPLEX_PROGRAM_ID
|| key.as_ref() == TENSOR_PROGRAM_ID
});
if involves_nft_program {
self.tx_sender.as_ref().map(|s| s.try_send(
TxEvent { slot, signature: info.signature.to_vec() }
));
}
Ok(())
}
Filtering vote transactions early is critical -- they account for roughly 90% of all Solana transactions and carry no useful data for most indexers.