All articles
Infrastructure·April 28, 2026·5 min read

Yellowstone gRPC Connection Pooling & Stream Multiplexing at Scale

A senior engineer's guide to yellowstone grpc connection pooling: partition subscriptions, beat HTTP/2 head-of-line blocking, and stay under provider caps at scale.

A single Yellowstone gRPC connection can carry a lot of subscriptions, right up until it can't. The moment you're tracking 300+ accounts and every slot update across a few program IDs, that one HTTP/2 socket becomes the bottleneck nobody warned you about. Not bandwidth — the multiplexing model underneath.

Yellowstone (the Geyser gRPC plugin most Solana data providers expose) rides on HTTP/2, and HTTP/2 multiplexes many logical streams over one TCP connection. That sounds like exactly what you want for hundreds of subscriptions. It mostly is, until you hit the two walls this post is about: head-of-line blocking inside the connection, and the per-connection subscription caps providers quietly enforce.

Why one connection stops scaling

HTTP/2 solves application-layer HOL blocking — logical streams don't wait on each other at the h2 framing level. But TCP underneath is still a single ordered byte stream. When you're pulling a firehose (say, all token account writes for a busy AMM), one slow-to-drain stream backs up the connection's flow-control window, and every subscription on that socket feels the latency. You'll see it as jittery SubscribeUpdate delivery: accounts that should update in the same slot arrive tens of milliseconds apart, clustered in bursts.

The h2 flow-control window is the specific thing to watch. Default SETTINGS_INITIAL_WINDOW_SIZE is 64 KB per stream, and there's a connection-level window on top. If your client doesn't send WINDOW_UPDATE frames fast enough — because your consumer task is blocked doing deserialization or, worse, synchronous work on the same thread — the server stops sending. The provider sees a slow reader and throttles you. This is the single most common cause of "Yellowstone feels laggy" that turns out to be entirely client-side.

Providers also cap things you don't control:

  • Max concurrent streams per connection (SETTINGS_MAX_CONCURRENT_STREAMS), often 100–256.
  • Max total account/slot filters per subscription, sometimes 1,000–10,000 depending on plan.
  • Connection count per API key. Some hard-cap at 3–5 simultaneous gRPC connections.

That last one is why "just open more connections" isn't a free lunch. You need pooling that respects the cap and distributes load intelligently across the connections you're allowed.

Connection pooling that actually respects the caps

The pattern that works: a fixed-size pool sized to your provider's connection cap, with each connection owning a disjoint partition of your subscription set. Don't round-robin individual updates across connections — that gives you nothing. Partition the subscriptions so each socket carries an independent slice of the load, and a slow stream on connection A can't stall connection B.

Partition by write frequency, not by count. If you naively split 300 accounts into 3 groups of 100, you'll still land a whale (a hot AMM vault, a market-maker's main account) that dominates one connection. Weight the partition by expected update rate. A rough approach:

// weight = observed updates/sec, fall back to 1.0 for unknown accounts
fn partition(subs: &[Sub], n_conns: usize) -> Vec<Vec<Sub>> {
    let mut buckets = vec![(0.0f64, Vec::new()); n_conns];
    let mut sorted = subs.to_vec();
    sorted.sort_by(|a, b| b.weight.partial_cmp(&a.weight).unwrap());
    // greedy: drop each sub into the currently-lightest bucket
    for s in sorted {
        let i = buckets.iter()
            .enumerate()
            .min_by(|a, b| a.1.0.partial_cmp(&b.1.0).unwrap())
            .map(|(i, _)| i).unwrap();
        buckets[i].0 += s.weight;
        buckets[i].1.push(s);
    }
    buckets.into_iter().map(|(_, v)| v).collect()
}

That's a longest-processing-time greedy bin-pack. It won't be optimal, but it keeps your hottest accounts spread out, which is the whole point. Re-run it periodically — a market maker who was quiet at 09:00 UTC can become your heaviest stream by 14:00.

For the mechanics of getting a fast, low-jitter connection in the first place — staked routing, endpoint selection, the whole delivery-path story — we cover the upstream side in our guide to staked connections and swQoS RPC, and the same routing discipline that helps transaction submission helps stream ingestion. Provider quality is not fungible.

Keeping the consumer ahead of the window

The client-side fix for HOL blocking is boring and mandatory: never block the gRPC receive task. The stream reader should do nothing but pull SubscribeUpdate messages and shove them onto a bounded channel. Deserialization, decode, business logic — all of that happens on separate worker tasks.

let (tx, rx) = tokio::sync::mpsc::channel(8192);
tokio::spawn(async move {
    while let Some(update) = stream.next().await {
        // hot path: no parsing, no locks, just hand off
        if tx.send(update?).await.is_err() { break; }
    }
});

Size that channel deliberately. Too small and you apply backpressure that throttles the whole connection. Too large and you're hiding a consumer that can't keep up — you'll OOM during a volatility spike instead of failing loud. Instrument channel depth and alert when it stays above ~70% for more than a second or two. That number is your early warning that a partition needs rebalancing or a worker pool needs more threads.

One more gotcha: ping intervals. Yellowstone servers will drop a connection they think is dead, and some proxies in front of them are aggressive about it. Send h2 pings every 10–15 seconds and set your keepalive timeout below the provider's idle cutoff. When a connection does drop mid-slot, you need resubscribe logic that replays the partition's filters and — critically — handles the gap. You lost updates between disconnect and reconnect. Reconcile by re-fetching account state over regular RPC for that partition before trusting the stream again.

Slot subscriptions deserve their own connection

Slot updates and commitment-level notifications are low-volume but latency-critical — they gate everything downstream. Put them on a dedicated, lightly-loaded connection so a busy account stream can never delay a slot or blockmeta update. This is the same reasoning behind splitting transaction submission across paths, which we get into in the dual-submission piece on sendTransaction vs Jito bundles. Separate the time-critical signal from the bulk data. If you're building the surfacing layer for all this, our trading dashboard work leans on exactly this partitioning so the slot clock never stutters.

Wiring the ingestion tier so it holds up at load — pool sizing, partition rebalancing, backpressure alarms, reconnect reconciliation — is most of the actual engineering. We build and operate that layer as part of our infrastructure and data services, and for teams running fast execution we pair it with direct leader forwarding as described in our TPU client and QUIC leader-forwarding writeup. For a live Solana market-making operation, the difference between a well-partitioned pool and a single overloaded socket is the difference between quoting on fresh state and quoting on state that's three slots stale.

If your Yellowstone ingestion is starting to jitter under subscription load, our infra and data team can help you partition and pool it properly.

Need a bot like this built?

We design, build and run trading bots on Solana, Hyperliquid and Polymarket.

Start a project
#yellowstone-grpc#solana#http2#connection-pooling#infrastructure