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.

SSE and gRPC-family streaming for browser clients Matrix comparing SSE with gRPC-Web or Connect server streaming on typing, encoding, reconnection, proxy compatibility, debuggability and tooling. SSE and gRPC-family streaming for browser clients Property SSE gRPC-Web / Connect streaming Typed messages JSON + schema protobuf, generated Encoding UTF-8 text binary or JSON Auto reconnect + resume built in application code Works through HTTP proxies yes usually, check buffering Debug with curl / DevTools readable binary framing Browser API EventSource generated client + fetch strength neutral weakness
gRPC brings schemas and generated code; SSE brings reconnection, resume and ubiquity. For browsers, SSE with a schema for payloads captures most of both.

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

Which streaming protocol for which client Decision tree choosing between native gRPC, gRPC-Web or Connect, and SSE based on whether the client is a service, whether a gRPC toolchain already exists in the frontend, and whether resume matters. Which streaming protocol for which client Client is a backend service? Native gRPC streaming yes no Frontend already on Connect/gRPC-Web? Connect server streaming yes no Need automatic reconnect + resume? SSE yes no SSE
Services talk gRPC natively. For browsers, the deciding question is usually who writes reconnection and resume.

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
Client code a browser team writes for a resumable typed stream Bar chart comparing approximate lines of client code for SSE with generated types versus Connect server streaming with a hand-written resume loop. Client code a browser team writes for a resumable typed stream SSE + generated types ~25 Connect streaming + resume loop ~70 approximate lines of client code, excluding generated files
Illustrative. The generated gRPC client removes serialisation code; the resume, backoff and permanent-error handling are what remain to write.

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.