A Solana logsSubscribe notification is a JSON-RPC 2.0 notification: it carries jsonrpc, method, and a params object with a subscription id and a LogsResponse result, and it has no id because the node pushes it unsolicited. The LogsResponse contains context.slot plus value.signature, value.err, and value.logs; context.slot is the ordering key when reassembling a stream, and value.err is null on success or a decoded error object on failure. Because value.logs is a flat string array, correlating an instruction to its logs requires tracking a depth counter across Program invoke and Program success/failed lines rather than reading a fixed index. Commitment levels can deliver the same slot more than once, so exactly-once consumers must dedupe by signature and prefer the strongest commitment seen. This guide separates documented protocol behavior from recommendations and from a reproducible measurement method you run against your own endpoint.
The PubSub notification envelope and why it is not a request/response
Solana's WebSocket PubSub API follows the JSON-RPC 2.0 specification, where a notification is a request object without an id and the server does not reply to it. The JSON-RPC 2.0 specification defines this envelope as jsonrpc, method, and params, with no id field. That single omission is the root of most parsing bugs: a client cannot correlate a notification to a JSON-RPC id because there is no per-notification id to match.
Instead, correlation happens through the subscription id. When you send a logsSubscribe request, the node responds with a result that is the subscription id. Every subsequent notification for that subscription carries that same id inside params.subscription. Your client must maintain a map from subscription id to its own local handler, and route each incoming frame through that map. This is the same correlation model used for other streaming methods and is covered in more depth in JSON-RPC notification ID correlation and batch ordering.
A practical consequence is that a single WebSocket connection can host many subscriptions, and frames from different subscriptions interleave arbitrarily. If you parse by assuming the next frame belongs to the last request you sent, you will misroute data as soon as you open a second subscription. The envelope is self-describing only through method and params.subscription, so treat those two fields as your routing keys.
- jsonrpc: always the string "2.0".
- method: the subscription method name, for example "logsNotification".
- params.subscription: the numeric subscription id returned by the subscribe call.
- params.result: the LogsResponse payload for this event.
- No id field: the node does not expect or send a response for a notification.
The LogsResponse shape: context, slot, signature, err, and logs
The Solana documentation for logsSubscribe defines the notification result as a LogsResponse with two top-level fields: context and value. context contains a slot number. value contains signature, err, and logs. That is the entire contract, and every field matters for correct parsing.
context.slot is the ordering key for reassembling a stream. The signature identifies the transaction, but signatures are not monotonic and do not tell you the order in which the node observed events. If you are building a time-ordered view of activity, sort or bucket by context.slot first, then by signature within a slot. Treating the signature as an ordering key produces a stream that looks shuffled under load.
value.logs is a flat array of strings. It is not a structured tree, not an array of objects, and not grouped by instruction. The node emits the same textual lines you would see in a transaction's log messages, in the order the runtime produced them. Any structure you want, such as which logs belong to which instruction, must be reconstructed by your parser. The companion guide on decoding getTransaction meta: logs and inner instructions covers the same log format from the RPC side, and the decoding logic is reusable.
- context.slot: the slot in which the node observed the transaction; use it for ordering.
- value.signature: the transaction signature; use it for deduplication.
- value.err: null on success, or a decoded error object on failure.
- value.logs: a flat string array in runtime emission order.
The err field: null on success and a decoded failure object otherwise
value.err is null when the transaction succeeded. When the transaction failed, value.err is an object that mirrors the transaction meta err. A common shape is {InstructionError: [index, reason]}, where index is the zero-based instruction index and reason is a string or nested object describing the failure. Because the shape matches getTransaction meta, the same decoder you already use for RPC responses can be reused here without modification.
This is where a subtle and expensive bug lives. A client that treats any non-null err as a transient network failure and retries the same transaction will resubmit a deterministic failure. If the error is an InstructionError caused by program logic, the retry will fail identically and may consume fees again. The correct interpretation is that the transaction was included and failed; the err field is a result, not a transport error.
Distinguish transport-level problems from on-chain failures. A dropped WebSocket connection, a timeout, or a JSON parse error is a transport problem. A non-null value.err inside a well-formed notification is an on-chain outcome. Your retry policy should apply only to the former, and your alerting should treat the latter as a business event.
- null: the transaction succeeded.
- {InstructionError: [index, reason]}: a specific instruction failed.
- Other object shapes may appear depending on the failure class; decode defensively.
- Never retry a transaction solely because value.err is non-null.
Reassembling a flat logs array with a depth counter
Because value.logs is a flat array, the only reliable way to associate logs with an instruction is to track nesting depth. The runtime emits lines such as "Program <ID> invoke [1]", "Program log: ...", "Program <ID> success", and "Program <ID> failed". The bracketed number on the invoke line is the depth. Each invoke increments depth; each success or failed decrements it. Logs emitted while depth is N belong to the invocation opened at depth N.
A depth counter lets you build a structured view: a stack of frames, each with a program id, a depth, and a list of log lines. When you see an invoke line, push a frame. When you see a Program log line, append it to the top frame. When you see success or failed, pop the frame and attach it to its parent. This is the same algorithm used for inner instruction decoding and is described in decoding getTransaction meta: logs and inner instructions.
Do not assume a fixed index. The number of log lines per instruction varies with compute budget, program behavior, and whether the program emits logs at all. A parser that reads logs[3] as "the first instruction's log" will break on the first transaction that emits a different number of lines. Depth tracking is the only stable approach.
- invoke lines open a frame and carry the depth in brackets.
- Program log lines append to the current top frame.
- success and failed lines close the current frame.
- Compute-unit lines are informational and do not change depth.
Commitment levels, duplicate signatures, and exactly-once processing
logsSubscribe accepts a commitment parameter, and the Solana documentation notes that confirmed and finalized can deliver the same slot's notification at different times. A finalized notification can supersede a confirmed one. If you subscribe at confirmed and later at finalized, or if you resubscribe after a reconnect, you may see the same signature more than once.
A consumer that needs exactly-once processing must dedupe by signature and prefer the strongest commitment seen. Keep a map from signature to the highest commitment level observed, and only emit a downstream event when the commitment improves or when the signature is new. This is a recommendation, not a protocol guarantee: the node does not promise a single delivery per signature.
The commitment caveat interacts with ordering. A confirmed notification for slot N may arrive after a finalized notification for slot N-1. If your downstream consumer assumes monotonic slots, it will see apparent regressions. Bucket by commitment level first, then order by context.slot within each bucket, and only promote a signature when its commitment strengthens.
- confirmed and finalized can both deliver the same slot.
- Dedupe by signature; prefer the strongest commitment.
- Do not assume monotonic context.slot across commitment levels.
- Resubscription after reconnect can replay recent events.
Runnable Node.js example: subscribe, decode, and track depth
The following example opens a WebSocket, subscribes with a mentions filter, decodes each notification, prints signature, slot, and err, and reassembles log depth. It uses the ws package and assumes a Solana JSON-RPC WebSocket endpoint. Replace the endpoint with your own provider URL. The same pattern applies whether you connect to a public endpoint or a managed one such as OnFinality's Solana network.
The parser is intentionally minimal. It does not dedupe by signature or handle commitment promotion; those are left as exercises described in the previous section. The goal is to show the envelope contract in working code so you can adapt it to your own pipeline.
const WebSocket = require('ws');
const ENDPOINT = process.env.SOLANA_WS_ENDPOINT;
const ws = new WebSocket(ENDPOINT);
let subscriptionId = null;
function parseLogs(logs) {
const stack = [];
const frames = [];
for (const line of logs) {
const invoke = line.match(/^Program (\S+) invoke \[(\d+)\]$/);
const done = line.match(/^Program (\S+) (success|failed)$/);
const log = line.match(/^Program log: (.*)$/);
if (invoke) {
stack.push({ programId: invoke[1], depth: Number(invoke[2]), logs: [] });
} else if (log && stack.length) {
stack[stack.length - 1].logs.push(log[1]);
} else if (done && stack.length) {
const frame = stack.pop();
frame.status = done[2];
frames.push(frame);
}
}
return frames;
}
ws.on('open', () => {
ws.send(JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'logsSubscribe',
params: [{ mentions: ['11111111111111111111111111111111'] }, { commitment: 'confirmed' }]
}));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id === 1 && msg.result) {
subscriptionId = msg.result;
console.log('subscribed with id', subscriptionId);
return;
}
if (msg.method !== 'logsNotification') return;
if (msg.params.subscription !== subscriptionId) return;
const { context, value } = msg.params.result;
console.log('slot', context.slot, 'sig', value.signature, 'err', value.err);
const frames = parseLogs(value.logs);
for (const f of frames) {
console.log(' program', f.programId, 'depth', f.depth, 'status', f.status);
}
});
ws.on('close', () => console.log('closed'));
ws.on('error', (e) => console.error('error', e.message));Results table: measuring notification behavior against your own endpoint
Provider behavior varies. The protocol contract is documented, but delivery timing, duplicate rates, and reconnect replay are provider-specific and should be measured, not assumed. The table below is a method you run against your own endpoint. Fill it in with observed values; do not treat any row as a universal constant.
Run the subscription for a fixed window, for example one hour, and record the counts. Repeat at confirmed and finalized to compare. If you operate multiple endpoints, run the same script against each and compare. This is the only reliable way to characterize your own delivery behavior.
- Endpoint URL: the WebSocket endpoint you tested.
- Commitment: confirmed or finalized.
- Window: start and end timestamps of the run.
- Total notifications received: count of logsNotification frames.
- Unique signatures: count of distinct value.signature values.
- Duplicate signatures: total minus unique.
- Non-null err count: notifications where value.err is not null.
- Max slot gap: largest difference between consecutive context.slot values.
- Reconnect replays: signatures seen again after a forced reconnect.
Troubleshooting: empty notifications, duplicates, and id collisions
Empty notifications usually mean the filter is too narrow or the commitment is too strict. A mentions filter only matches transactions that mention the given pubkey. If you filter on a program id, you will only see transactions that mention that program, not every transaction the program processes. Broaden the filter or subscribe with all and filter client-side. Also confirm the subscription id was captured before you started routing frames.
Duplicate signatures across commitment levels are expected, not a bug. If you subscribe at confirmed and finalized, or if you resubscribe after a reconnect, the same signature can appear more than once. Dedupe by signature and prefer the strongest commitment. If you see duplicates within a single commitment level, check whether your client opened two subscriptions and is routing both into the same handler.
A non-null err that is not a network failure is an on-chain outcome. Do not retry the transaction. Decode the error object, log the instruction index and reason, and route it to your business logic. If you are seeing a high rate of non-null err values, inspect the program logic rather than the transport.
Subscription id collisions after reconnect happen when a client reuses a stale id or fails to clear its map. On reconnect, discard all previous subscription ids, resubscribe, and rebuild the map from the new responses. The connection lifecycle details are covered in Solana RPC WebSocket methods and connection lifecycle.
- Empty stream: widen the filter or lower the commitment.
- Duplicates: dedupe by signature, prefer strongest commitment.
- Non-null err: on-chain failure, not a transport error.
- Id collisions: clear the subscription map on reconnect.
Limitations and tradeoffs of the logsSubscribe contract
logsSubscribe gives you logs, not structured instructions. You get a flat string array and must reconstruct structure yourself. This is flexible but puts the parsing burden on your client, and any change in log formatting can break your parser. The format is stable in practice but is not a versioned schema.
The notification envelope has no id, so you cannot use JSON-RPC id correlation. You must maintain your own subscription map. This is simple but easy to get wrong under reconnect or multi-subscription scenarios. The JSON-RPC notification ID correlation and batch ordering guide covers the general pattern.
Commitment levels do not guarantee exactly-once delivery. You must dedupe and promote. This adds state to your consumer and means you cannot treat the stream as a simple queue. If you need stronger guarantees, you may need to reconcile against getTransaction or a separate indexer.
Finally, WebSocket delivery is best-effort. The node may drop frames under load, and your client may miss events during a reconnect. For critical pipelines, treat logsSubscribe as a low-latency signal and reconcile against a durable source. The Solana WebSocket API (RPC Assistant) page and the Solana subscription filter mechanics guide cover related tradeoffs.
- Flat logs require client-side structure reconstruction.
- No id means you must maintain your own subscription map.
- Commitment levels require dedupe and promotion logic.
- WebSocket delivery is best-effort; reconcile for critical pipelines.
Next steps: from parsing to production pipelines
Once you can decode a notification, the next step is to make your consumer resilient. Add signature deduplication, commitment promotion, and a bounded buffer for out-of-order slots. Persist the highest processed slot so you can resume after a restart without reprocessing everything.
If you are running at scale, consider a managed endpoint to reduce operational overhead. OnFinality's Solana network provides WebSocket access, and the RPC pricing page describes plans. For teams that want a higher-level interface, the API service and the OnFinality Learn hub have related guides.
Finally, test your parser against real traffic. Use the results table above to characterize your endpoint, and revisit the Solana RPC WebSocket methods and connection lifecycle guide for reconnect and keepalive patterns. The combination of correct envelope parsing and resilient connection handling is what makes a logsSubscribe pipeline production-ready.
- Add dedupe, commitment promotion, and a bounded buffer.
- Persist the highest processed slot for restart safety.
- Consider a managed endpoint for scale.
- Test against real traffic and fill the results table.