← Back to guides
September 6, 2026·8 min read

Backpressure in Yellowstone gRPC: When Your Bot Is the Bottleneck

YellowstonePerformancegRPCSolanaArchitecture

A trader reports that latency degraded from 40ms to several seconds over an hour, and asks whether the endpoint is overloaded. Usually it is not. Usually the client is processing slower than the stream is producing, the queue between them is growing, and every event arrives later than the last. Latency that climbs monotonically rather than spiking is nearly always backpressure.

How to tell backpressure from a slow provider

Timestamp on receipt, before your handler runs, and compare against the slot's own time. Then measure your handler separately.

let received = 0;
let handlerNs = 0n;

stream.on("data", (update) => {
  received++;
  const t0 = process.hrtime.bigint();
  handle(update);                       // your work
  handlerNs += process.hrtime.bigint() - t0;
});

setInterval(() => {
  const avgMs = Number(handlerNs / BigInt(Math.max(received, 1))) / 1e6;
  // Solana produces roughly 2.5 slots/sec. If avgMs * events-per-slot
  // exceeds 400ms, you are structurally behind and the gap only grows.
  console.log({ received, avgHandlerMs: avgMs.toFixed(2) });
  received = 0;
  handlerNs = 0n;
}, 1_000);

Two signatures distinguish the cases. A slow provider gives you high latency that is roughly constant, and it appears immediately. Backpressure gives you latency that grows the longer you run, and it disappears the moment you restart — because restarting drops the queue.

Fix one: get the work out of the read loop

The read loop should do nothing but receive, filter cheaply, and enqueue. Any RPC call, any database write, and any Jito submission inside the loop stalls the next read for its entire duration.

// Bounded queue + fixed worker pool. Bounded is the important word:
// an unbounded queue does not remove backpressure, it converts it into
// memory growth and then a crash.
const QUEUE_MAX = 10_000;
const queue: Update[] = [];

stream.on("data", (update) => {
  if (queue.length >= QUEUE_MAX) {
    metrics.dropped++;          // shed deliberately, and count it
    return;
  }
  queue.push(update);
});

for (let i = 0; i < 8; i++) void worker();   // 8 concurrent handlers

async function worker() {
  for (;;) {
    const update = queue.shift();
    if (!update) { await sleep(1); continue; }
    await handle(update);       // slow work lives here, not in the read loop
  }
}

Fix two: filter server-side, not in your handler

The cheapest event is the one never sent. A client that subscribes to all transactions and then discards 99% of them in JavaScript is paying decode cost, queue cost, and bandwidth for data it does not want. Narrow the SubscribeRequest instead — accountInclude on the specific program, and accountExclude for the noise you know you never need. The filter reference covers the combination rules, which are easy to get subtly wrong.

Fix three: cut decode cost per event

Decoding is per-event work in the hot path, so it scales with market activity exactly when you can least afford it. We measured it across three languages:

Raw (full)
Raw, no base58
Pre-parsed JSON
TypeScript (Node)18.47 µs732 ns1.05 µsPython46.91 µs9.74 µs3.77 µsRust9.53 µs85 ns1.11 µs
Nanoseconds of client-side decode work per event. Bars share one scale. The gap between “raw (full)” and “raw, no base58” is the cost of encoding pubkeys as strings, which is the bulk of what pre-parsed JSON actually saves.

The finding that matters for backpressure is which part is expensive. Protobuf and Borsh decoding are cheap. Base58-encoding pubkeys into strings dominates, because base 58 is not a power of two, so encoding requires repeated division over a 32-byte integer. If your handler compares pubkeys as byte arrays and never renders them, skip the encoding entirely and most of that cost disappears — no provider change required. Full method and caveats on the decode cost benchmark.

Decide what to drop before you are forced to

Under sustained overload something gets dropped. The only question is whether you chose it. A bot that must never miss a Pump.fun creation but can happily miss a routine swap should encode that priority explicitly, rather than letting a FIFO queue decide by arrival order.

// Two queues, one priority. Under load the low queue starves first,
// which is the outcome you would have picked anyway.
const high: Update[] = [];   // creations, migrations
const low: Update[] = [];    // everything else

stream.on("data", (u) => {
  const q = isCreation(u) ? high : low;
  if (q.length < (q === high ? 50_000 : 5_000)) q.push(u);
  else metrics.dropped++;
});

const next = () => high.shift() ?? low.shift();

What to alert on

Queue depth, not latency. Latency is the symptom and it lags; queue depth is the cause and it leads. Alert when depth exceeds a fraction of your cap and stays there, and log dropped-event counts as a first-class metric rather than a debug line. A bot that silently sheds 5% of events during every volatile window is making trading decisions on partial data, and nothing in your latency graph will tell you.

Related

Ready to try it?

Get your API key and start receiving filtered data in under 5 minutes. Free tier available.

Get started →