# 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.

- Canonical: https://triport.io/blog/sse-vs-websocket-vs-grpc-blockchain-data
- Published: 2026-09-22
- Updated: 2026-10-05
- Author: Triport Engineering
- Category: Engineering
- Networks: Stellar, Ethereum, Solana

## TL;DR

- Pick by delivery semantics, not by fashion: SSE is one-way HTTP with a browser-native client, WebSocket is a bidirectional socket with per-subscription ids, and gRPC is a typed HTTP/2 stream with a server-side filter set.
- None of the three live channels discussed here replays history for you. Stellar's mounted SSE starts at cursor=now, /ws/sol-stream is at-most-once with no backfill, and a dropped eth_subscribe must be re-sent — so every design needs its own cursor and a REST or JSON-RPC catch-up path.
- Availability decides more than taste: on Triport, SSE exists for Stellar Horizon (status Limited: one upstream operator, best-effort, no dedicated SLA), WebSocket pub/sub for Solana, Ethereum and Polygon, and gRPC for Solana Yellowstone; BSC and TON push channels were not mounted when we measured on 2026-09-22.

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 `https://grpc.triport.io` 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](https://triport.io/streams/yellowstone-grpc).

## Direction and framing

| | SSE | WebSocket (JSON-RPC pub/sub) | gRPC (Yellowstone) |
|---|---|---|---|
| Direction | Server → client only | Both ways | Both ways (`SubscribeRequest` in, `SubscribeUpdate` out) |
| Framing | Text events, `data:` lines | JSON-RPC messages in WS frames | Protobuf messages over HTTP/2 |
| Changing what you receive | Open a different URL | Send another `*Subscribe` or unsubscribe by id | Send a new `SubscribeRequest` |
| Multiple feeds per connection | One stream per request | Many subscriptions, routed by id | One filter set with named filters |
| Typed schema | Whatever the `data:` payload is | JSON, per method | Generated 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:

```bash
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](https://triport.io/learn/websocket-subscription-reconnect).

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 disconnect | SSE (Stellar, Triport) | WebSocket pub/sub | gRPC / `/ws/sol-stream` |
|---|---|---|---|
| Stream resumes from your position | No — `cursor=now` only | No — resubscribe | No — re-send `SubscribeRequest` |
| Your checkpoint | `paging_token` from each frame | Block number or slot you last processed | Slot you last processed |
| Gap-fill path | Horizon REST from the stored token | `eth_getLogs` / `getSignaturesForAddress` | `getBlock` / `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](https://triport.io/docs/stellar/guides/monitor-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:

| Network | SSE | WebSocket | gRPC |
|---|---|---|---|
| Stellar | Horizon `ledgers` and `transactions`, `cursor=now`; status Limited (best-effort, no dedicated SLA) | — | — |
| Solana | — | [`/ws/sol`](https://triport.io/docs/streaming) pub/sub; Firehose [`/ws/sol-stream`](https://triport.io/docs/streaming/sol-stream), a fixed non-vote transaction feed with no client filters | Yellowstone `geyser.Geyser` at `https://grpc.triport.io` |
| Ethereum | — | [`/ws/eth`](https://triport.io/docs/streaming/eth-ws): `newPendingTransactions`, `logs`, `newHeads`, `syncing` | — |
| Polygon | — | [`/ws/poly`](https://triport.io/docs/streaming/poly-ws) pub/sub, live on both regions (2026-09-22) | — |
| BNB Smart Chain | — | Planned, not mounted (404) | — |
| TON | — | None 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](https://triport.io/docs/stellar/streams/sse): two channels, `cursor=now` required, auth forms, no replay.
4. Triport, [Streaming overview](https://triport.io/docs/streaming): 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](https://triport.io/docs/streaming/sol-stream): fixed feed, frame fields, no client filters, reconnect and backfill guidance.
6. Triport, [Ethereum Pub/Sub `/ws/eth`](https://triport.io/docs/streaming/eth-ws): 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).

## Try it

- Reference: [Streaming overview — every channel with its measured mount status](https://triport.io/docs/streaming)
- Network: [Stellar RPC — Horizon, Soroban and SSE on one key](https://triport.io/stellar-rpc)
