SSE vs gRPC Server Streaming Permalink to this section
Part of SSE vs WebSockets vs HTTP Polling, under SSE Protocol Fundamentals & Architecture.
Teams that already use gRPC between services often ask whether the browser-facing stream should be gRPC too. gRPC server streaming offers typed messages defined in protobuf, generated clients and efficient binary encoding. Server-Sent Events offer native browser support with automatic reconnection and resume, and compatibility with every HTTP intermediary. Browsers cannot speak native gRPC, so the real comparison is SSE versus gRPC-Web or the Connect protocol. This guide compares them for browser clients and for service-to-service use.
Symptom & Developer Intent Permalink to this section
- The backend exposes a gRPC streaming RPC, and the frontend team needs the same data live in the browser.
- A gRPC-Web stream works in development but fails behind the production proxy or on some browsers.
- Streams end silently and the client does not reconnect.
- The team wants typed events in the browser and is unsure whether SSE can provide them.
The intent is a clear choice per client type, and a bridge where both are needed.
Root Cause Analysis Permalink to this section
Native gRPC relies on HTTP/2 features browsers do not expose to JavaScript, notably trailers and fine-grained control of streams. gRPC-Web and Connect adapt gRPC to what browsers can do: server streaming works (over fetch with a streaming body), client and bidirectional streaming generally do not. They add a proxy or a server implementation that speaks the adapted protocol.
The silent-end symptom is the most important practical difference. A server stream in gRPC-Web or Connect is a single fetch; when it ends — normally, or because a proxy cut it — the generated client reports completion or an error, and reconnecting with the right position is the application’s job. EventSource does it by default.
Step-by-Step Resolution Permalink to this section
Step 1 — Decide per client type Permalink to this section
Step 2 — Bridge a gRPC stream to SSE when the backend is gRPC Permalink to this section
An edge service can subscribe to the internal gRPC stream and re-expose it as SSE, adding ids and replay:
// Edge handler: consume the internal gRPC stream, emit SSE frames.
func (e *Edge) Stream(w http.ResponseWriter, r *http.Request) {
flusher := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
after, _ := strconv.ParseUint(r.Header.Get("Last-Event-ID"), 10, 64)
stream, err := e.orders.Watch(r.Context(), &pb.WatchRequest{AfterSeq: after, UserId: userID(r)})
if err != nil {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
return
}
for {
msg, err := stream.Recv() // ends when r.Context() is cancelled
if err != nil {
return
}
data, _ := protojson.Marshal(msg) // typed on the server, JSON on the wire
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", msg.Seq, msg.Kind, data)
flusher.Flush()
}
}
The internal RPC takes a resume position (AfterSeq), which the bridge fills from Last-Event-ID. Cancelling the request context cancels the gRPC stream, so a browser disconnect releases the upstream call.
Step 3 — Keep types in the browser with a shared schema Permalink to this section
Typed payloads do not require gRPC. Generate TypeScript types from the same protobuf definitions (or from JSON Schema) and parse data into them:
import { OrderEvent } from './gen/orders_pb'; // generated from the .proto
es.addEventListener('order', (e) => {
const evt = OrderEvent.fromJsonString((e as MessageEvent).data); // validated, typed
store.apply(evt);
});
Step 4 — If you choose Connect or gRPC-Web, add reconnection Permalink to this section
async function watchWithResume(client, req) {
let after = req.afterSeq ?? 0n;
for (;;) {
try {
for await (const msg of client.watch({ ...req, afterSeq: after })) {
after = msg.seq;
handle(msg);
}
} catch (err) {
if (isPermanent(err)) throw err; // unauthenticated, permission denied
}
await sleep(backoff.next()); // stream ended or failed: resume
}
}
This is the loop EventSource gives you for free; with gRPC-family protocols it must be written and tested explicitly.
Step 5 — Check the proxy path for either choice Permalink to this section
Both protocols stream over ordinary HTTP responses in the browser, so both are exposed to the same intermediaries: a proxy that buffers, a load balancer idle timeout, a CDN that caps response duration. gRPC-Web additionally depends on a translating proxy (commonly Envoy’s gRPC-Web filter) or on a server that implements the protocol natively, which is one more component whose timeouts and buffering must be configured for long-lived responses. Connect’s protocol can be served directly by the application in several languages, removing that component. With SSE, the checklist in proxy and CDN configuration for SSE applies as-is.
Service-to-service streams have different constraints. Inside the data centre, native gRPC over HTTP/2 gives flow control per stream, deadlines, cancellation that propagates across services, and efficient binary encoding — none of which SSE offers as well. A common architecture therefore uses gRPC streaming between backend services and translates to SSE only at the edge where browsers connect. The translation point is also the natural place to enforce per-user authorisation, rate limits and conflation, because it is the first component that knows which human is on the other end.
Finally, consider who maintains the client. A gRPC toolchain in the frontend means code generation in the build, generated-code updates on every schema change, and a runtime library in the bundle. For teams that already have it, adding a streaming call is cheap. For teams that do not, SSE plus generated types for payloads delivers most of the type safety with a smaller footprint.
Validation & Monitoring Permalink to this section
# SSE bridge: resume works and frames are readable.
curl -sN -H 'Last-Event-ID: 1042' https://app.example.com/api/orders/stream | head -6
# gRPC side: the internal stream honours AfterSeq.
grpcurl -d '{"after_seq":1042,"user_id":"u42"}' orders.internal:443 orders.v1.Orders/Watch | head
Monitor stream lifetimes and reconnects on whichever path you choose; streams ending at a fixed duration indicate proxy timeouts on either protocol.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can browsers use native gRPC?
No. Browsers do not give JavaScript the HTTP/2 control native gRPC needs. gRPC-Web and Connect adapt the protocol for browsers, supporting unary and server-streaming calls.
Is protobuf smaller than JSON over SSE?
Usually, but SSE requires text, so protobuf would need base64, which removes much of the saving. JSON with compact field names is typically close enough for browser feeds.
Do I lose type safety with SSE?
Not if payloads are defined in a schema and parsed with generated code. The transport is untyped; the messages need not be.
Where should the bridge live?
At the edge of the service tier, close to where browsers connect, so the internal gRPC stream stays inside the network and each browser stream maps to one upstream call.