TL;DR

  • Solana's native WebSockets don't scale well, deliver account updates at slot boundaries, and have no way to subscribe to full transaction data
  • For anyone building real-time frontends, that means connection throttling, stale data between slot boundaries, and custom workarounds to patch the gaps
  • Triton was the first Solana RPC provider to replace the native pubsub path with gRPC-backed WebSocket streaming
  • Whirligig is a Rust proxy between your WebSocket client and Dragon's Mouth gRPC, that improves the speed and reliability you see with zero changes to your code or billing
  • You get intra-slot account updates at processed commitment, stable blockSubscribe and transactionSubscribe out of the box, and flexible connection limits
  • transactionSubscribe delivers complete transaction data in a single subscription, replacing the signatureSubscribe + getTransaction polling loop

The problem with Solana's native WebSocket pubsub

Every Solana RPC node ships with WebSocket pubsub built in (though Anza plans to phase it out of Agave eventually). It works for basic subscriptions, but if you're building anything real-time at scale, you'll run into 4 architectural limits:

1. Connection caps. Solana's RPC node design limits how many concurrent WebSocket connections it can handle, making providers throttle your subscriptions to stay below it.

2. Notification bottleneck. Native WebSockets run inside the same process as all JSON-RPC handling, so notification delivery competes with request processing for resources. Under heavy RPC load, WebSocket updates can lag by seconds or drop entirely, even when you're within the connection limit.

3. Update timing. At processed commitment, native pubsub batches account notifications to slot boundaries, so multiple changes within a single slot collapse into one update after the slot finishes. For latency-sensitive apps like trading frontends or explorers, that delay manifests as UI lag for your users.

4. Incomplete functionality. blockSubscribe method is flagged unstable, and transactionSubscribe doesn't exist at all. Getting full transaction data in real time means subscribing to a signature, waiting for confirmation, then calling getTransaction separately, resulting in 2 round-trips and 2 billed calls per transaction.

Triton's Yellowstone gRPC already solved these problems. It captures state changes at the validator's memory level via Geyser and delivers them from streaming node clusters built for this workload.

But gRPC isn't accessible from browsers, so frontends were still stuck on native pubsub. We built Whirligig to close that gap, bringing Dragon's Mouth's speed and reliability over the standard WebSocket API.

How Whirligig solves it

Whirligig is a Rust binary between your WebSocket client and Dragon's Mouth gRPC. It accepts standard Solana WebSocket calls, translates them into gRPC subscriptions under the hood, and streams the results back to you. Your code stays the same, while you get every benefit of this infrastructure upgrade:

Intra-slot account updates. At processed commitment, Whirligig streams account updates as they're processed within a slot, delivering data faster.

blockSubscribe. Complete block notifications are fully supported, with filters for commitment level, transaction detail granularity, and reward visibility.

jsonParsed encoding. accountSubscribe and programSubscribe support jsonParsed encoding, so parsable programs (SPL Token, Token-2022, Stake, Vote, Nonce, Sysvar, Address Lookup Table, BPF Loader, Config) return structured, human-readable objects instead of raw byte arrays. Token accounts include resolved mint metadata.

transactionSubscribe. Subscribe once with filters for accounts, signatures, vote/failed transactions, and get the full transaction back, including metadata, log messages, token balances, and compute units.

Higher subscription limits. Whirligig doesn't enforce a strict subscription cap, and our bare-metal nodes have been stress-tested under hundreds of thousands of concurrent subscriptions with stable performance.

Complete pubsub parity. Every standard Solana WebSocket subscription works through Whirligig, and all three commitment levels (processed, confirmed, finalised) are supported.

Getting started

If you're on Triton's shared infrastructure, your existing WebSocket connections already route through Whirligig by default. No code changes required.

If you're not, self-onboarding takes ~2 minutes:

Once you have an endpoint, append /whirligig to your existing Triton endpoint and connect. All standard Solana WebSocket subscriptions work as-is, no library changes needed.

Rust — uses the standard solana-pubsub-client crate, the same client Agave ships with:

use futures::StreamExt;
use solana_account_decoder_client_types::UiAccountEncoding;
use solana_commitment_config::CommitmentConfig;
use solana_pubkey::Pubkey;
use solana_pubsub_client::nonblocking::pubsub_client::PubsubClient;
use solana_rpc_client_types::config::RpcAccountInfoConfig;
use std::str::FromStr;

#[tokio::main]
async fn main() {
    let client = PubsubClient::new("wss://<your-endpoint>.rpcpool.com/<token>/whirligig")
        .await
        .expect("connect");

    let pubkey = Pubkey::from_str("<account-pubkey>").expect("valid pubkey");
    let config = RpcAccountInfoConfig {
        encoding: Some(UiAccountEncoding::JsonParsed),
        commitment: Some(CommitmentConfig::processed()),
        data_slice: None,
        min_context_slot: None,
    };

    let (mut stream, unsubscribe) = client
        .account_subscribe(&pubkey, Some(config))
        .await
        .expect("subscribe");

    while let Some(update) = stream.next().await {
        println!("{}", serde_json::to_string(&update).unwrap());
    }

    drop(stream);
    drop(unsubscribe);
    client.shutdown().await.expect("shutdown");
}

JavaScript (web3.js) — point @solana/web3.js at the Whirligig wsEndpoint, everything else stays the same:

import { Connection, PublicKey } from "@solana/web3.js";

const connection = new Connection(
  "https://<your-endpoint>.rpcpool.com/<token>",
  {
    wsEndpoint: "wss://<your-endpoint>.rpcpool.com/<token>/whirligig",
    commitment: "processed",
  }
);

const subscriptionId = connection.onAccountChange(
  new PublicKey("<account-pubkey>"),
  (accountInfo, context) => {
    console.log("slot:", context.slot, "lamports:", accountInfo.lamports);
  },
  "processed"
);

// When done:
// await connection.removeAccountChangeListener(subscriptionId);

JavaScript (raw WebSocket) — required for transactionSubscribe, which delivers full transaction data in a single call and isn't available in @solana/web3.js:

const ws = new WebSocket("wss://<your-endpoint>.rpcpool.com/<token>/whirligig");

ws.addEventListener("open", () => {
  // Keep alive — Whirligig closes idle connections after 60 s
  setInterval(() => ws.send(JSON.stringify({ jsonrpc: "2.0", method: "ping" })), 30_000);

  ws.send(JSON.stringify({
    jsonrpc: "2.0", id: 1,
    method: "transactionSubscribe",
    params: [
      { vote: false, failed: false },
      { commitment: "confirmed", encoding: "jsonParsed", transactionDetails: "full" },
    ],
  }));
});

ws.addEventListener("message", ({ data }) => {
  const msg = JSON.parse(data);
  if (msg.method === "transactionNotification") {
    console.log("tx:", msg.params.result.signature);
  }
});