Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Network & Protocol Guides13 min read

Solana WebSocket Subscription Filters: Why Filters Miss Events

How Solana subscription filters are evaluated, why a well-formed filter can silently match nothing, and how to verify a subscription is delivering what you intended.

TL;DR

Solana subscription filters are evaluated server-side against raw account bytes or log text, and a filter that is syntactically valid can still match nothing without producing any error. Only programSubscribe accepts a compound filter array of memcmp and datasize entries; accountSubscribe takes a single account pubkey and logsSubscribe takes either the string all or a mentions array, so a getProgramAccounts filter array copied onto a subscription is rejected or misapplied. memcmp compares raw bytes at a named offset against a base58 or byte value, which means the offset must already account for any discriminator or header the program writes ahead of the field, and an off-by-eight Anchor discriminator is the classic silent miss. datasize pins the account data length and breaks when a program changes its layout, and because the filters array is a conjunction, one stale entry can zero out the whole subscription. The only reliable verification is to reconcile a filtered subscription against an unfiltered subscription or an HTTP getProgramAccounts read with the same filter.

Subscription Filter Vocabulary Across Solana WebSocket Methods

Solana exposes three account- and log-oriented WebSocket subscriptions, and each one accepts a different filter shape. The Solana programSubscribe documentation defines a filter object with memcmp and datasize entries, while the logsSubscribe documentation defines either the literal string all or an object with a mentions array. accountSubscribe is the outlier: it takes a bare account pubkey and no filter at all, so there is nothing to misconfigure and nothing to narrow.

This asymmetry is the first source of silent failure. Developers who already use the HTTP getProgramAccounts filter API naturally assume the same array works everywhere, but the subscription methods do not share that vocabulary. If you are still mapping the connection layer, the Solana RPC WebSocket methods and connection lifecycle guide covers how subscriptions are opened, confirmed, and closed, and the Solana WebSocket API (RPC Assistant) page is the fastest way to inspect a live method signature before you commit to a filter shape.

The practical rule is to pick the method that matches the granularity you actually need. If you want every account owned by a program, programSubscribe is the only method with a compound filter. If you want a single account, accountSubscribe is correct and filtering is unnecessary. If you want to observe program activity rather than account state, logsSubscribe with mentions is the right tool, and the Parsing logsSubscribe notification payloads guide covers what arrives in the notification body.

  • programSubscribe: filter object with memcmp and datasize entries, evaluated against account data bytes.
  • logsSubscribe: the string all, or { mentions: [pubkey] }, evaluated against log text.
  • accountSubscribe: a single account pubkey, no filter field, no narrowing possible.
  • Copying a getProgramAccounts filter array onto accountSubscribe or logsSubscribe is rejected or ignored depending on the provider.

memcmp Semantics: Byte Offset, Encoding, and the Anchor Discriminator Trap

A memcmp filter names a byte offset into the account data and a value encoded as base58 or as a byte array, and it matches only when the raw bytes at that offset equal the supplied value. The comparison is byte-exact and case-sensitive; there is no normalization, no alignment, and no awareness of your program's field layout. The offset is an absolute position in the account data buffer, not a field index.

That absolute-offset rule is where most silent misses originate. Anchor-based programs write an eight-byte discriminator ahead of the account body, so a field that appears first in the Rust struct actually begins at offset 8 in the serialized data. A filter authored at offset 0 against that field will compare the discriminator bytes instead and match nothing, forever, without an error. The same class of mistake appears with any hand-rolled header, version byte, or length prefix the program writes before the field you care about.

Encoding is the second trap. A pubkey field stored as 32 raw bytes will not match a base58 string that decodes to the same pubkey if you pass the string where bytes are expected, and a numeric field stored little-endian will not match a big-endian byte array. Always derive the filter value from the same serialization path your program uses, and confirm the offset by reading one known account with getAccountInfo and inspecting the raw base64 data before you subscribe.

  • Offset is absolute in the account data buffer, not a field index.
  • Anchor programs place an eight-byte discriminator before the body; field offsets start at 8.
  • memcmp is byte-exact and case-sensitive; base58 and byte-array encodings are not interchangeable.
  • Verify the offset against a real account's raw data before opening the subscription.

datasize Semantics and Layout Brittleness

A datasize filter is a length match on the account data. It is useful for excluding closed accounts, which may still be observed briefly, and for separating accounts that share an owner but differ in shape. It is also the most brittle filter in the vocabulary, because it encodes an assumption about your program's serialized layout that changes the moment you add a field, reorder a struct, or bump a version.

The failure mode is quiet. When a program ships a version-3 layout with a larger account, a datasize filter pinned to the version-2 length stops matching new accounts while continuing to match any legacy accounts that still exist. You see a partial stream, not an error, and the missing accounts are exactly the ones you most likely wanted. Treat datasize as a version marker you must update in lockstep with program deployments, and prefer it as a secondary filter rather than the primary selector.

Because the filters array is a conjunction, datasize interacts badly with memcmp when layouts diverge. A memcmp on an owner-indexed field combined with a datasize on a version-2 layout yields nothing once the program ships version 3, even though each filter is individually well-formed. If you need to track multiple layouts during a migration, run separate subscriptions per layout rather than trying to express the union in one filter array.

  • datasize matches the total account data length, not a field length.
  • It silently stops matching when a program changes its account layout.
  • Combining datasize with memcmp across a layout migration can zero out the entire subscription.
  • Run one subscription per layout during migrations instead of encoding a union.

Conjunction Semantics and the Idle-Subscription Ambiguity

Every entry in a programSubscribe filters array must match for a notification to be delivered. There is no OR, no negation, and no partial credit. This is straightforward when you author the array deliberately, but it becomes a hazard when filters are assembled from different sources, such as a memcmp copied from an indexer and a datasize copied from a migration script. One stale entry is enough to suppress every notification.

The deeper problem is that a subscription matching nothing is indistinguishable from a subscription that is correct but idle. The node sends no error, no warning, and no periodic heartbeat tied to filter evaluation. A subscription that is silently broken looks exactly like a healthy subscription on a quiet program. This is why filter correctness cannot be validated by observing the subscription alone; it must be validated against an independent read.

The reliable check is reconciliation. Open an unfiltered programSubscribe alongside the filtered one, or issue an HTTP getProgramAccounts read with the same filter, and compare counts over the same window. If the filtered subscription returns zero while the unfiltered one returns accounts that satisfy your intended predicate, the filter is wrong. The getProgramAccounts filters and pagination guide covers the HTTP side of that comparison, and the Building a resumable account-set indexer guide covers keeping the two views consistent over time.

  • The filters array is a logical AND; every entry must match.
  • No error is emitted when a filter matches nothing.
  • A broken subscription and an idle subscription are observationally identical.
  • Reconcile against an unfiltered subscription or an HTTP getProgramAccounts read.

Runnable Example: Counting Filtered Versus Unfiltered Notifications

The following Node.js script opens two programSubscribe subscriptions against the same program, one unfiltered and one with a memcmp plus datasize filter, counts notifications over a fixed window, and reports the mismatch. It uses the standard ws package and the public JSON-RPC 2.0 subscription envelope. Replace the program id, the memcmp offset, and the filter value with values derived from your own program's serialized layout.

Run it against a program you know is active. If the unfiltered subscription receives notifications and the filtered one receives none, your filter is wrong; if both receive nothing, the program is simply idle in that window and you should extend the window or pick a busier program. This is the measurement method, not a benchmark: the numbers you record are specific to your endpoint, your program, and your observation window.

const WebSocket = require('ws');

const WS_URL = process.env.SOLANA_WS_URL || 'wss://api.mainnet-beta.solana.com';
const PROGRAM_ID = process.env.PROGRAM_ID;
const WINDOW_MS = 60000;

// Derive these from your program's serialized layout.
// Anchor programs write an 8-byte discriminator before the body.
const MEMCMP_OFFSET = 8;
const MEMCMP_BYTES = Buffer.from(process.env.MEMCMP_BASE58 || '', 'base64');
const DATASIZE = Number(process.env.DATASIZE || 165);

function subscribe(label, filter, counter) {
  const ws = new WebSocket(WS_URL);
  ws.on('open', () => {
    ws.send(JSON.stringify({
      jsonrpc: '2.0',
      id: label,
      method: 'programSubscribe',
      params: [PROGRAM_ID, filter ? { encoding: 'base64', filters: filter } : { encoding: 'base64' }]
    }));
  });
  ws.on('message', (raw) => {
    const msg = JSON.parse(raw.toString());
    if (msg.method === 'programNotification') counter.count += 1;
    if (msg.error) console.error(label, 'error', msg.error);
  });
  ws.on('error', (err) => console.error(label, 'socket error', err.message));
  return ws;
}

const unfiltered = { count: 0 };
const filtered = { count: 0 };

const wsA = subscribe('unfiltered', null, unfiltered);
const wsB = subscribe('filtered', [
  { memcmp: { offset: MEMCMP_OFFSET, bytes: MEMCMP_BYTES.toString('base64') } },
  { dataSize: DATASIZE }
], filtered);

setTimeout(() => {
  console.log('window_ms', WINDOW_MS);
  console.log('unfiltered_notifications', unfiltered.count);
  console.log('filtered_notifications', filtered.count);
  console.log('mismatch', unfiltered.count > 0 && filtered.count === 0);
  wsA.close();
  wsB.close();
  process.exit(0);
}, WINDOW_MS);

Results Table: Filter Type, What It Matches, and the Silent Failure

Use the table below as a worksheet. Fill the right-hand column with what you actually observe on your own endpoint and program, then compare it against the documented behavior. The point is not to trust a published number but to reproduce the comparison yourself, because filter correctness depends entirely on your program's serialized layout.

Record the observation window, the program id, the exact filter array you sent, and the counts from both subscriptions. If the filtered count is zero while the unfiltered count is non-zero, the failure is in the filter, not the network. If both are zero, extend the window before drawing any conclusion.

  • Filter type: memcmp | What it matches: raw bytes at a named offset equal to the supplied value | Common silent failure: offset does not account for an 8-byte discriminator or header | Your observation: ____
  • Filter type: datasize | What it matches: total account data length equals the supplied integer | Common silent failure: program changed layout and the pinned length is stale | Your observation: ____
  • Filter type: memcmp + datasize | What it matches: both conditions true simultaneously | Common silent failure: one stale entry suppresses the whole subscription | Your observation: ____
  • Filter type: logsSubscribe mentions | What it matches: log lines mentioning the pubkey | Common silent failure: expecting account-state semantics from a log filter | Your observation: ____
  • Filter type: accountSubscribe | What it matches: a single account pubkey, no filter | Common silent failure: passing a filter array that the method does not accept | Your observation: ____

Troubleshooting: Offset Drift, Encoding, Case, and Commitment

Offset drift after a program upgrade is the most common cause of a subscription that used to work and now returns nothing. When a program adds a field before the one you filter on, every subsequent offset shifts. Re-derive the offset from the current serialized layout and re-run the reconciliation script. If you maintain an indexer, the Building a resumable account-set indexer guide covers how to detect and recover from this class of drift without losing events.

Encoding mismatches are the second most common cause. A memcmp value supplied as base58 when the method expects bytes, or vice versa, will compare the wrong byte sequence and match nothing. Confirm the encoding your provider documents, and derive the value from the same serialization path your program uses. Case sensitivity follows from the same principle: base58 is case-sensitive, and there is no case-insensitive matching anywhere in the filter vocabulary.

Commitment level affects what you observe, not what the filter matches. A subscription at processed commitment may deliver notifications for accounts that are later rolled back, while confirmed or finalized commitment delivers fewer, later notifications. If your filtered and unfiltered subscriptions use different commitment levels, your reconciliation counts will disagree for reasons unrelated to the filter. Hold commitment constant across both sides of the comparison. Provider-specific behavior around commitment defaults and notification timing is documented per provider and varies, so check your endpoint's documentation rather than assuming parity.

  • Re-derive memcmp offsets after every program upgrade that changes the layout.
  • Confirm base58 versus byte encoding against your provider's documented method signature.
  • There is no case-insensitive matching in memcmp; base58 is case-sensitive.
  • Hold commitment level constant across filtered and unfiltered subscriptions.
  • Provider defaults for commitment and notification timing vary; consult endpoint documentation.

Verifying a Subscription with an HTTP Cross-Check

A second verification path uses HTTP rather than a second WebSocket. Issue a getProgramAccounts call with the same filter array you passed to programSubscribe, and compare the returned account set against the notifications you observed. The HTTP call is a point-in-time read, so it will not match a streaming count exactly, but it will tell you immediately whether the filter matches any accounts at all.

This cross-check is cheap and catches the most expensive class of bug: a filter that is syntactically valid and semantically empty. The getProgramAccounts filters and pagination guide covers the HTTP filter API in detail, including how pagination interacts with large result sets. If the HTTP call returns accounts and the subscription returns nothing, the problem is in the subscription parameters, not the filter predicate.

curl -s https://api.mainnet-beta.solana.com -X POST -H 'Content-Type: application/json' -d '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "getProgramAccounts",
  "params": [
    "YOUR_PROGRAM_ID",
    {
      "encoding": "base64",
      "filters": [
        { "memcmp": { "offset": 8, "bytes": "YOUR_BASE58_OR_BASE64_VALUE" } },
        { "dataSize": 165 }
      ]
    }
  ]
}'

Limitations and Tradeoffs of Server-Side Subscription Filtering

Server-side filtering reduces bandwidth and client-side work, but it moves correctness into a place you cannot observe. The node evaluates your filter against raw bytes and returns nothing when it does not match, so every filter bug becomes a silent data gap rather than a visible error. That tradeoff is acceptable for exploratory work and dangerous for anything that must be complete.

Filters also cannot express unions, negations, or predicates over decoded fields. If you need accounts matching one of several layouts, or accounts whose decoded owner field is in a set, you must either run multiple subscriptions or subscribe unfiltered and filter client-side. Client-side filtering costs bandwidth but gives you visibility: you can log what was rejected and detect drift immediately.

Finally, filter semantics are tied to the serialized layout, which is an implementation detail of your program rather than a stable interface. Any filter you author is coupled to a specific program version. Treat filter definitions as versioned artifacts that ship alongside program deployments, and re-verify them with the reconciliation method whenever the layout changes. For endpoint selection and capacity planning around high-volume subscriptions, see RPC pricing and the API service overview, and browse the Solana network page for endpoint options.

  • Server-side filtering hides correctness; a filter bug is a silent data gap.
  • No union, negation, or decoded-field predicates are expressible in the filter array.
  • Client-side filtering costs bandwidth but makes rejections observable.
  • Filter definitions are coupled to a specific program version and must be versioned.

Next Steps: Instrumenting Subscriptions for Continuous Verification

The durable fix is to make verification continuous rather than a one-time check. Run a low-frequency unfiltered subscription or a periodic getProgramAccounts read alongside your filtered subscription, and alert when the filtered stream goes quiet while the unfiltered reference is active. This turns a silent failure into a detectable one.

Pair that with a small amount of client-side validation: decode the accounts you do receive and assert that they satisfy the predicate you intended, not just the predicate you encoded. If a received account fails the intended predicate, your filter is too loose; if the reference stream shows accounts that your filter should have matched but did not arrive, your filter is too tight. Both directions are informative.

For teams building on managed infrastructure, the Solana WebSocket API (RPC Assistant) page documents the method surface, and the OnFinality Learn hub collects the related guides on connection lifecycle, log parsing, and indexer construction. Start from the Solana network page to pick an endpoint, then wire the reconciliation script into your deployment pipeline so filter drift is caught at release time rather than in production.

  • Run a reference stream and alert when the filtered stream goes quiet.
  • Decode received accounts and assert the intended predicate, not just the encoded one.
  • Treat filter definitions as versioned artifacts checked at release time.
  • Review connection lifecycle and log parsing guides before scaling subscription count.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started