Why browsers can't speak native gRPC
gRPC is defined on top of HTTP/2, and it relies on features that browser JavaScript cannot reach. A gRPC response sends its messages in DATA frames and its outcome in a trailing HEADERS frame. The grpc-status and grpc-message fields arrive as trailers, after the body. A call that streamed every message successfully can still fail at the last moment, and the only place that failure is recorded is the trailers.
Browsers hide all of this. fetch and XMLHttpRequest give you a status line, headers, and a body. They give you no access to frames, and in practice they give you no response trailers. The browser also decides the transport. It negotiates HTTP/1.1, HTTP/2, or HTTP/3 with the server and any intermediaries, and page code cannot require HTTP/2. On the request side, gRPC expects the client to send TE: trailers, but TE is a forbidden header name, so scripts cannot set it. A browser therefore cannot produce a conforming gRPC request, and it cannot read the status of the response.
There are two common ways around this, and both change the protocol instead of the browser. gRPC-Web moves the trailers into the response body so a browser can read them. You typically deploy it with a translating proxy, such as Envoy, placed in front of unchanged gRPC servers. Connect is a separate protocol designed to work with ordinary HTTP semantics. Its server libraries handle Connect, gRPC, and gRPC-Web on the same handlers.
The main trade-off is where the cost lands. With gRPC-Web you operate a proxy. With Connect you adopt its server framework.
How gRPC-Web and Connect work on the wire
gRPC-Web keeps gRPC's framing. Each message is prefixed with a 1-byte flag and a 4-byte big-endian length. Browsers can't read HTTP trailers, so gRPC-Web moves grpc-status and grpc-message into a final frame in the response body, marked by the high bit of the flag byte. A request carries Content-Type: application/grpc-web+proto and X-Grpc-Web: 1. The response usually returns HTTP 200 even when the call fails, because the real status sits in that trailer frame. The application/grpc-web-text mode base64-encodes the body. It exists for clients that can't handle binary streaming responses. gRPC-Web runs over HTTP/1.1 or HTTP/2.
Connect handles unary calls as ordinary HTTP. A unary call is a POST to /package.Service/Method with Content-Type: application/json or application/proto, plus an optional Connect-Protocol-Version: 1 header. That means curl -H 'Content-Type: application/json' -d '{"name":"Ada"}' works without generated code. A failed call returns a real status, such as 404, with a body like {"code":"not_found","message":"..."}. Streaming calls switch to application/connect+protoenvelopes, and errors arrive in a flagged end-of-stream message. Methods markedNO_SIDE_EFFECTS` can also be called with GET, with the message encoded in the query string, so CDNs and browsers can cache the response.
| Aspect | gRPC-Web | Connect |
|---|---|---|
| Framing | Length-prefixed, always | Unary: none; streaming: envelopes |
| Error location | Trailer frame in body | HTTP status + JSON body |
| Encodings | Binary, base64 text | Protobuf, JSON |
| Caching | Not practical (POST only) | GET for idempotent calls |
| Debuggability | Needs a decoder | Readable in DevTools and curl |
# 1) Connect unary call: a plain POST with a JSON body, no generated code needed.
curl -i -X POST \
-H 'Content-Type: application/json' \
-H 'Connect-Protocol-Version: 1' \
-d '{"name":"Ada"}'
# Success: HTTP/2 200, content-type: application/json, body {"greeting":"Hello, Ada!"}
# Failure: HTTP/2 404, content-type: application/json
# body {"code":"not_found","message":"no user named Ada"}
# 2) Connect GET for a method marked NO_SIDE_EFFECTS (server must allow GET).
# The message goes in the query string, so a CDN or browser can cache it.
curl -i --get \
--data-urlencode 'connect=v1' \
--data-urlencode 'encoding=json' \
--data-urlencode 'message={"name":"Ada"}'
# 3) gRPC-Web call: the body must be a length-prefixed frame.
# Flag 0x00 + 4-byte big-endian length (5) + protobuf bytes for {name: "Ada"}.
printf '\x00\x00\x00\x00\x05\x0a\x03Ada' |
curl -s -D - -X POST \
-H 'Content-Type: application/grpc-web+proto' \
-H 'X-Grpc-Web: 1' \
--data-binary @- --output - | xxd
# Headers: HTTP/2 200, content-type: application/grpc-web+proto
# (the status is 200 even when the call fails)
# Body: 00 <len> <message bytes> data frame
# 80 <len> grpc-status:0\r\n... trailer frame (high bit set in flag byte)
# The real result is the grpc-status value in that final frame.
The same Greet call made three ways: a Connect JSON POST, a cacheable Connect GET, and a hand-framed gRPC-Web request whose status arrives in a trailer frame inside a 200 response body.
Deployment models: Envoy gRPC-Web proxy vs Connect servers
In the proxy model, the browser talks gRPC-Web to Envoy, and Envoy translates each request into native gRPC over HTTP/2 to your existing backends. The backends stay unchanged. The minimal configuration is an HTTP connection manager with three filters in order: envoy.filters.http.grpc_web, then envoy.filters.http.cors, then envoy.filters.http.router. The router must be last because it forwards the request upstream, and any filter placed after it never runs. CORS must also come before the router so that preflight OPTIONS requests are answered at the edge. They never reach a gRPC backend, which would not understand them. The CORS filter alone is not enough. You also need a CORS policy on the route or virtual host that allows origins and gRPC-Web headers such as x-grpc-web and grpc-timeout, and exposes grpc-status and grpc-message to the browser.
That extra hop has real costs. The proxy is another component to configure, scale, and patch, and it terminates TLS. Over time it also accumulates browser-specific policy such as allowed origins, auth header handling, timeouts, and message size limits, separate from the service code that owns those concerns for other clients.
In the direct model, a Connect handler serves Connect, gRPC, and gRPC-Web on the same path. It routes each request by Content-Type:
application/grpcfor gRPCapplication/grpc-webfor gRPC-Webapplication/protoorapplication/jsonfor Connect unary calls
This removes the proxy, but only if a Connect server library exists for your backend language. Where none exists, you are back to running a proxy.
static_resources:
listeners:
- name: browser_ingress
address: { socket_address: { address: 0.0.0.0, port_value: 8080 } }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: grpc_web
route_config:
virtual_hosts:
- name: grpc_services
domains: ["*"]
# Browser traffic policy lives here, at the proxy, not in the service code.
typed_per_filter_config:
envoy.filters.http.cors:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.CorsPolicy
allow_origin_string_match: [{ exact: "" }]
allow_methods: "POST, OPTIONS"
allow_headers: "content-type,x-grpc-web,x-user-agent,grpc-timeout"
expose_headers: "grpc-status,grpc-message"
routes:
- { match: { prefix: "/" }, route: { cluster: grpc_backend } }
# Order matters: translate gRPC-Web, answer CORS preflight at the edge, then forward. Router must be last.
http_filters:
- { name: envoy.filters.http.grpc_web, typed_config: { "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb } }
- { name: envoy.filters.http.cors, typed_config: { "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors } }
- { name: envoy.filters.http.router, typed_config: { "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router } }
clusters:
- name: grpc_backend
type: STRICT_DNS
# The unchanged backend speaks native gRPC, so the upstream connection must use HTTP/2.
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config: { http2_protocol_options: {} }
load_assignment:
cluster_name: grpc_backend
endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: grpc-backend, port_value: 50051 } } } }] }]
A minimal Envoy config for the proxy model: the grpc_web, cors and router filters in the required order, a virtual-host CORS policy that allows gRPC-Web request headers and exposes grpc-status and grpc-message, and an HTTP/2 upstream to a native gRPC backend (TLS omitted for brevity).
Client code generation and switching protocols
Both protocols start from the same .proto files, and that is what makes switching cheap. The Protobuf-ES code generator, typically run through buf generate, emits message types and a service descriptor. The descriptor lists method names, input and output types, and streaming kinds. It contains nothing about the wire protocol. The protocol is chosen at runtime by the transport you pass to createClient.
The Connect browser library ships two transports with the same interface:
- gRPC-Web:
createGrpcWebTransport({ baseUrl: "" })sends gRPC-Web frames. An existing Envoy gRPC-Web filter accepts these requests without changes. - Connect:
createConnectTransport({ baseUrl: "" })sends the Connect protocol. It defaults to JSON for unary calls. SetuseBinaryFormat: trueto send Protobuf instead.
Call sites, generated code and error handling stay the same. A frontend can launch against the gRPC-Web proxy you already run. Once a Connect-capable server is in place, moving to Connect is a one-line transport swap behind a config flag, and it requires no schema change or regeneration.
Moving to JSON brings two pitfalls:
- Field names. Proto3 JSON uses lowerCamelCase by default, so
user_idbecomesuserId, unlessjson_nameoverrides it. Hand-written fixtures, logs and non-generated consumers often assume snake_case. Conformant parsers accept both spellings, but they emit only camelCase. - 64-bit integers.
int64,uint64and the fixed64 variants are serialized as JSON strings, because a JavaScript number cannot hold every 64-bit value exactly. Protobuf-ES maps these fields tobigintby default. Any code that runsNumber()on an ID, or compares it to a numeric literal, can silently lose precision. Audit those call sites before you switch formats.
import { createClient, type Transport } from "@connectrpc/connect";
import { createConnectTransport, createGrpcWebTransport } from "@connectrpc/connect-web";
// Generated by `buf generate` (Protobuf-ES) from user/v1/user.proto:
// rpc GetUser(GetUserRequest) returns (GetUserResponse);
// message GetUserRequest { int64 user_id = 1; }
import { UserService } from "./gen/user/v1/user_pb";
const baseUrl = "";
type Protocol = "grpc-web" | "connect";
// The only protocol-specific code. Flip the flag; nothing else changes.
function makeTransport(protocol: Protocol): Transport {
if (protocol === "grpc-web") {
// gRPC-Web frames, accepted by the existing Envoy gRPC-Web filter.
return createGrpcWebTransport({ baseUrl });
}
// Connect protocol. JSON by default; set true to send binary Protobuf.
return createConnectTransport({ baseUrl, useBinaryFormat: false });
}
const client = createClient(UserService, makeTransport("grpc-web"));
export async function loadUser(id: bigint): Promise<string> {
// Generated field is camelCase (userId). On the JSON wire it is also
// "userId", not "user_id", so snake_case fixtures will not match output.
const res = await client.getUser({ userId: id });
// int64 maps to bigint and travels in JSON as a string, e.g.
// {"userId":"9007199254740993"}
// Number(id) would round that to 9007199254740992 without any error.
if (id === 0n) {
// Compare against a bigint literal, not the number 0.
throw new Error("user id must be set");
}
// Keep IDs as strings at UI boundaries instead of converting to number.
return id.toString() + ": " + JSON.stringify(res.toJson?.() ?? {});
}
One generated client used with either a gRPC-Web or a Connect transport chosen by a single flag, with comments marking where camelCase JSON names and string-encoded 64-bit integers cause problems.
Limits and infrastructure pitfalls for browser gRPC
Precision is not the only thing that differs from a backend-to-backend setup. Several limits only appear once real browsers and real proxies sit in the path.
Streaming is half the menu. Browsers cannot reliably stream a request body, so both protocols support only unary and server-streaming calls. Client and bidirectional streaming need a WebSocket or a separate channel, such as a unary "send" RPC paired with a server stream for "receive."
Buffering kills streams silently. A CDN, load balancer or compression middleware that buffers responses still delivers every message. It just delivers them all at once when the stream closes. Nothing errors, so check every hop for response buffering before you depend on server streaming.
CORS and timeouts. Both protocols use non-simple content types, so every call triggers a preflight. Your CORS policy must allow the protocol headers: x-grpc-web, x-user-agent and grpc-timeout for gRPC-Web, or connect-protocol-version and connect-timeout-ms for Connect. For gRPC-Web, it must also expose grpc-status and grpc-message. If an intermediary strips the timeout header, the server never sees the client's deadline.
Text mode costs bytes and CPU. Base64 inflates payloads by about a third, and both ends spend CPU encoding and decoding.
| Concern | gRPC-Web | Connect |
|---|---|---|
| Failed unary call in access logs | HTTP 200; status in trailers | Mapped HTTP status (e.g. 404, 503) |
| Failed streaming call | HTTP 200 | HTTP 200; error in end-of-stream message |
| Status-code alerting | Blind without trailer parsing | Works for unary |
| Gateway/observability recognition | Commonly understood | Often seen as generic HTTP |
That last row matters. Tools that classify gRPC traffic by content type may not label Connect requests as RPCs, so you lose per-method metrics.
When to choose gRPC-Web, Connect, or something else
The choice depends mostly on who owns the server and what already sits between it and the browser.
Choose gRPC-Web behind a proxy when your backends are gRPC services in several languages that you can't or won't modify, or when an Envoy-based mesh or edge layer already exists. The translation cost then becomes a filter on infrastructure you already operate, and service teams don't need to change anything.
Choose Connect when you control the server framework. Serving browser traffic directly removes a network hop and a component to configure. Unary calls become plain POST requests with JSON bodies, so they are readable in browser devtools and you can call them with curl during an incident. Connect servers can generally also accept gRPC and gRPC-Web, so existing clients keep working.
Run both during a migration. Leave the proxy in front of services you haven't converted, and move each service to Connect as its owning team gets to it.
Look elsewhere in two cases. If consumers expect conventional REST, use JSON transcoding through HTTP annotations in your protobuf definitions. If you need true bidirectional streaming in the browser, use WebSockets, because neither protocol can offer it over browser fetch.
| Scenario | Recommendation |
|---|---|
| Polyglot gRPC backends you can't change | gRPC-Web via proxy |
| Envoy mesh or edge proxy already deployed | gRPC-Web via proxy |
| You own the server framework | Connect |
| Mid-migration, mixed ownership | Both, service by service |
| Public or third-party REST consumers | JSON transcoding |
| Browser needs bidirectional streams | WebSockets |
Migration path and impact on cost and engineering time
Stage the cutover, not the rewrite
Start with the services whose server code you own. A Connect handler serves gRPC, gRPC-Web, and the Connect protocol on the same endpoint, so changing a service's server implementation breaks no existing caller. Backend clients keep using native gRPC over HTTP/2, and browser clients still on gRPC-Web keep working. For services you can't rewrite yet, put a translation layer in front of them and leave the backend untouched.
Next, move browser clients one app at a time by changing the transport in the generated client. The generated stubs stay the same, so application code shouldn't need to change. Track proxy traffic per route. When gRPC-Web requests to a service reach zero, delete that route from the Envoy config. Retire the proxy only after its last route is gone. Service-to-service traffic does not change at any point.
Where the cost moves
The migration replaces one cost with another. The proxy is infrastructure you have to configure, upgrade, and debug. Its failures often show up in the browser as generic CORS or network errors that don't say which hop failed. Adopting Connect means learning a new server library and touching every handler. In return, you can call endpoints with curl and plain JSON, and errors come from your own code.
Illustrative estimate (assumed numbers, not a benchmark):
- Debugging hours saved per year = proxy-related incidents per year × hours saved per incident
- Migration effort = services migrated × days per service
With 12 incidents a year and 3 hours saved on each, you save 36 hours a year. Migrating 10 services at 1 day each costs 10 days of effort. Replace these with your own numbers to see whether the migration pays off.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to grpc-web vs connect, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.