TriportRPC

SSE vs WebSocket vs gRPC: choosing a transport for blockchain data

Three ways to receive a push from a blockchain node, compared on what they actually guarantee — direction, framing, auth, resume and filtering — with one real chain for each.

Most "WebSocket vs gRPC" articles for blockchain developers compare two transports and rank them by speed. That leaves out the third transport that chains actually ship — Server-Sent Events — and it measures the wrong property. For a backend that turns chain events into money movements, the questions that decide the design are: who can talk on the connection, how messages are framed, how you authenticate, what happens after a disconnect, and where the filter lives. An Alchemy overview of webhooks, WebSockets and gRPC that ranks for these queries (read 2026-09-22) does not mention Server-Sent Events at all.

This post compares the three on those questions, using one real channel for each: Stellar Horizon over SSE, Ethereum eth_subscribe over WebSocket, and Solana Yellowstone over gRPC. Triport's /ws/sol-stream is covered where it differs: it is a fixed transaction feed, not a Yellowstone bridge.

What each transport is on the wire

Server-Sent Events is a plain HTTP response that never ends. The server answers with the MIME type text/event-stream and writes UTF-8 text events separated by blank lines; each event carries data: lines and optionally id:, event: and retry: fields. The format and the browser client (EventSource) are defined in the WHATWG HTML standard. There is no upgrade handshake and no second protocol on top of HTTP.

WebSocket starts as an HTTP request and upgrades to a framed, full-duplex socket. On top of it, blockchain nodes speak JSON-RPC pub/sub: you send eth_subscribe (EVM) or accountSubscribe (Solana), the node acknowledges with a subscription id, and notifications arrive tagged with that id.

gRPC runs over HTTP/2 with protobuf messages and generated client stubs. Solana's Yellowstone interface is the relevant one here: the geyser.Geyser service exposes a Subscribe stream (you send SubscribeRequest messages, the server sends SubscribeUpdate messages) plus unary calls such as GetVersion. On Triport the service is reached at triport.io:443 with the key in gRPC metadata (x-token), and the docs pin the proto version the server advertises: 13.1.0. The product overview is on the Yellowstone gRPC page.

Direction and framing

SSEWebSocket (JSON-RPC pub/sub)gRPC (Yellowstone)
DirectionServer → client onlyBoth waysBoth ways (SubscribeRequest in, SubscribeUpdate out)
FramingText events, data: linesJSON-RPC messages in WS framesProtobuf messages over HTTP/2
Changing what you receiveOpen a different URLSend another *Subscribe or unsubscribe by idSend a new SubscribeRequest
Multiple feeds per connectionOne stream per requestMany subscriptions, routed by idOne filter set with named filters
Typed schemaWhatever the data: payload isJSON, per methodGenerated from .proto

The direction column matters more than it looks. With SSE you cannot tell the server anything after the request is sent, so every choice — channel, filter, starting point — has to be expressed in the URL. With WebSocket and gRPC you can reshape the stream mid-connection, which is what makes them suitable for watch-lists that change often.

The framing column matters for filter design. eth_subscribe hands back a hex-encoded subscription id per call, and you keep a map from id to purpose; a single socket can hold several subscriptions side by side. Yellowstone gRPC works the other way round: you send one SubscribeRequest describing every account, transaction, slot, block and entry filter you want, and each new request atomically replaces the previous set. There is no incremental add or per-filter unsubscribe; to change anything you resend the whole set. Triport's /ws/sol-stream takes no request at all: it pushes the same feed of non-vote transactions (slot, signature, fee) to every client and ignores client messages, so any filtering happens on your side.

Browser reality: EventSource cannot set headers

If the consumer is a browser, the transport choice is partly made for you. The EventSource constructor takes a URL and one option, withCredentials; there is no way to attach an Authorization or x-token header (MDN and the WHATWG EventSourceInit dictionary list that single member). Triport's streaming docs anticipate the same problem on WebSocket: a client that cannot set headers on the upgrade can send the key in an auth frame after connecting.

That is why SSE and WebSocket endpoints usually accept a key in the query string. Triport's Stellar SSE route takes x-token, Authorization: Bearer, or ?api-key=, and the WebSocket channels accept a header, the auth frame, or ?api-key=. A key in a URL is visible to anything that records URLs, and a key shipped to a browser is visible to its user, so in practice you proxy the stream through your own backend and keep the key server-side. gRPC sits on the other side of that line: Triport documents its Yellowstone service as a surface for backend applications.

A backend client, by contrast, can send any header on any of the three:

curl -N \
  -H "Accept: text/event-stream" \
  -H "x-token: $TRIPORT_API_KEY" \
  "https://triport.io/rpc/stellar/horizon/transactions?cursor=now"

Resume semantics: what happens after a disconnect

This is where the three differ most, and where most production incidents come from.

The SSE standard has a built-in resume hook: the browser remembers the last id: it saw and, on reconnect, sends it back in the Last-Event-ID request header; the server's retry: field sets the reconnection delay. Whether a server honours that header is up to the server. Triport's mounted Stellar SSE route does not offer replay: both channels require cursor=now, a missing or different cursor is a 400, and the reference says plainly that the route does not promise replay from an older paging token. What the stream does give you is a paging_token on every transaction frame — and Horizon's REST collections accept that token as a cursor. So the resume path is REST, not the stream.

On WebSocket, a subscription lives only as long as the socket. Subscription ids are not portable across connections: after a reconnect you send eth_subscribe or accountSubscribe again and store the new id. Anything that happened in between is simply not delivered. Why a reconnected listener goes quiet, and how to resubscribe and backfill, is covered in why a WebSocket subscription goes silent.

On gRPC and on /ws/sol-stream, delivery is documented as at-most-once with no replay or backfill of frames produced before you connected. On gRPC, filter state is not persisted server-side, so after a drop the client reconnects and re-sends its SubscribeRequest; on /ws/sol-stream there is nothing to re-send. To fill the gap, the docs point to JSON-RPC reads such as getBlock or getSignaturesForAddress over the missed range.

After a disconnectSSE (Stellar, Triport)WebSocket pub/subgRPC / /ws/sol-stream
Stream resumes from your positionNo — cursor=now onlyNo — resubscribeNo — re-send SubscribeRequest
Your checkpointpaging_token from each frameBlock number or slot you last processedSlot you last processed
Gap-fill pathHorizon REST from the stored tokeneth_getLogs / getSignaturesForAddressgetBlock / getSignaturesForAddress

The conclusion is the same for all three: the stream is a trigger, your stored cursor is the source of truth, and a paged read closes the gap. Pick the transport whose checkpoint is easiest for you to store.

Filtering: where the filter lives

  • URL-scoped (SSE). On Triport, Horizon SSE mounts exactly two channels, ledgers and transactions. There is no per-account or payments channel, so you receive every newly ingested transaction and filter on your side, or use the stream only as a "something happened" signal and read your account's payments over REST. The existing guide to monitoring Stellar payments with Horizon SSE is the full implementation of that pattern.
  • Per-subscription (WebSocket). eth_subscribe takes a type and an optional filter: logs accepts one or more contract addresses and up to four topic positions, each a single topic, an OR-array, or null as a wildcard. Solana's accountSubscribe watches one public key per call. Filtering is exact, but each target costs a subscription.
  • Server-side filter set (gRPC). A Yellowstone SubscribeRequest is a map of named filters. A transaction filter can require that a transaction touch any of accountInclude, all of accountRequired, and none of accountExclude, and can include or drop vote and failed transactions. The filter names come back on every update, so one connection can serve several consumers.

The trade-off is volume versus control. SSE with a coarse channel moves the most bytes you do not need; a gRPC filter set moves the fewest, at the cost of a generated client and a proto version to track.

Where each is actually offered, by chain

A transport comparison is only useful if the chain you need offers the transport. This is what Triport exposes, from our docs and production checks on 2026-09-22:

NetworkSSEWebSocketgRPC
StellarHorizon ledgers and transactions, cursor=now; status Limited (best-effort, no dedicated SLA)
Solana/ws/sol pub/sub; /ws/sol-stream fixed non-vote transaction feed, no client filtersYellowstone geyser.Geyser at triport.io:443
Ethereum/ws/eth: newPendingTransactions, logs, newHeads, syncing
Polygon/ws/poly pub/sub, live on both regions (2026-09-22)
BNB Smart ChainPlanned, not mounted (404)
TONNone published (404)

Tier gates apply per channel: on /ws/eth, newPendingTransactions is free, logs is basic, and newHeads and syncing are pro; /ws/sol-stream and gRPC start at pro, gRPC is limited by concurrent streams (8 on pro, 20 on business), and neither gRPC nor /ws/sol-stream is limited by requests per second.

When this is the wrong approach

  • You need history, not a feed. None of these channels replays the past. If the job is "every payment to this address since March", start with paged reads and add a stream later as a trigger.
  • You need exactly-once processing from the stream alone. All three deliveries here are at-most-once from the consumer's point of view. Exactly-once is something you build with a stored cursor and idempotent writes keyed on transaction ids.
  • Your chain has no push channel. On TON and BSC today, polling a JSON-RPC method on a timer is the design, not a fallback.
  • A handful of accounts in a browser. Opening gRPC through a proxy for five Solana accounts is overhead; accountSubscribe on /ws/sol from your backend is simpler.

Sources

  1. WHATWG HTML Standard, Server-sent events (text/event-stream, Last-Event-ID, retry, EventSourceInit) — https://html.spec.whatwg.org/multipage/server-sent-events.html, read 2026-09-22.
  2. MDN, EventSource() constructor (options: withCredentials only) — https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource, read 2026-09-22.
  3. Triport, Horizon SSE reference: two channels, cursor=now required, auth forms, no replay.
  4. Triport, Streaming overview: channel table, per-method tiers, the /ws/sol-stream fixed feed, at-most-once delivery. Mount states re-checked on production on 2026-09-22, on both the EU and CA origins.
  5. Triport, /ws/sol-stream reference: fixed feed, frame fields, no client filters, reconnect and backfill guidance.
  6. Triport, Ethereum Pub/Sub /ws/eth: four subscription types, hex subscription ids, logs filter shape.
  7. Alchemy, "Webhooks vs WebSockets vs gRPC" — https://www.alchemy.com/overviews/webhooks-vs-websockets-vs-grpc, read 2026-09-22 (searched for "SSE", "Server-Sent", "EventSource": not found).