Definition
- gRPC-Web – a variant of gRPC that packs the final status into the response body so browsers can read it over HTTP/1.1 or HTTP/2, since browser APIs cannot access HTTP/2 trailers.
- Envoy proxy – sits between the browser and the gRPC backend; its
grpc_webfilter unwraps gRPC-Web requests into native gRPC upstream and wraps responses back. - CORS filter – required because browsers enforce cross-origin rules and send preflight
OPTIONSrequests. - Limitation – gRPC-Web supports unary and server streaming but not client streaming or bidirectional streaming.
- Backend unchanged – the gRPC server stays pure native gRPC and never needs modification.
Imagine National Bank of India's internal departments speak a fast shorthand code (native gRPC over HTTP/2). A customer at the public counter (the browser) only speaks plain language (HTTP/1.1). The bank places a translator at the front desk (Envoy) who converts the customer's request into the internal shorthand and translates the reply back. The back office never changes, and the customer never learns the shorthand.
Understanding the Concepts
Why can browsers not speak native gRPC?
Native gRPC depends on HTTP/2 trailers to carry the final call status. Browser networking APIs like fetch and XMLHttpRequest do not give access to HTTP/2 trailers or frame-level control. So you cannot point a browser directly at a native gRPC server.
gRPC-Web solves this by defining a slightly different wire format that packs the status into the response body, which a browser can read over HTTP/1.1 or HTTP/2.
How does the Envoy grpc_web filter work?
Envoy sits between the browser and the gRPC backend. You configure an HTTP connection manager with two filters:
- The
grpc_webfilter unwraps gRPC-Web framing and re-emits a native gRPC request upstream, then wraps the native gRPC response (including trailers) back into gRPC-Web for the browser. - The CORS filter handles cross-origin preflight requests.
# envoy.yaml
static_resources:
listeners:
- name: grpc_web_listener
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
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
route_config:
name: local_route
virtual_hosts:
- name: grpc_backend
domains: ["*"]
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: "GET, POST, OPTIONS"
allow_headers: "content-type, x-grpc-web, x-user-agent"
expose_headers: "grpc-status, grpc-message"
routes:
- match: { prefix: "/" }
route:
cluster: grpc_backend
clusters:
- name: grpc_backend
type: STRICT_DNS
lb_policy: ROUND_ROBIN
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: account-service
port_value: 9090
How do you generate browser stubs?
You generate gRPC-Web stubs using protoc with the grpc-web plugin. The browser then calls the stub just like a normal gRPC client.
# Generate gRPC-Web stubs for JavaScript
protoc --js_out=import_style=commonjs:./generated \
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:./generated \
account.proto
// Browser JavaScript: call gRPC-Web stub
const { AccountServiceClient } = require('./generated/account_grpc_web_pb');
const { BalanceRequest } = require('./generated/account_pb');
const client = new AccountServiceClient('http://envoy-proxy:8080');
const request = new BalanceRequest();
request.setAccountId('RAJESH-001');
client.getBalance(request, {}, (err, response) => {
if (err) {
console.error('Error:', err.message);
} else {
console.log('Balance:', response.getBalancePaise());
}
});
What is the Java gRPC backend?
The backend is pure gRPC and does not need any modification for gRPC-Web support. Envoy handles the translation transparently.
package com.bank.grpc;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;
import java.math.BigDecimal;
public class AccountServer {
public static void main(String[] args) throws Exception {
Server server = ServerBuilder.forPort(9090)
.addService(new AccountServiceImpl())
.build()
.start();
System.out.println("gRPC server started on port 9090");
server.awaitTermination();
}
static class AccountServiceImpl extends AccountServiceGrpc.AccountServiceImplBase {
@Override
public void getBalance(
BalanceRequest request,
StreamObserver<BalanceResponse> responseObserver) {
BigDecimal balance = new BigDecimal("50000.00");
long paise = balance.movePointRight(2).longValue();
responseObserver.onNext(
BalanceResponse.newBuilder()
.setBalancePaise(paise)
.build());
responseObserver.onCompleted();
}
}
}
What are the streaming limitations?
gRPC-Web supports:
| Streaming type | Supported? | Reason |
|---------------|-----------|--------|
| Unary | Yes | Single request, single response |
| Server streaming | Yes | Server sends multiple messages, browser reads them |
| Client streaming | No | Browser cannot stream a request body frame by frame |
| Bidirectional streaming | No | Requires both client and server streaming |
How is this different from building a REST layer?
Instead of building and maintaining a separate REST API that duplicates the gRPC contract, gRPC-Web lets the browser use the same protobuf contract. The backend stays pure gRPC, and Envoy handles the translation.
Without gRPC-Web: Browser ──REST──▶ REST Controller ──▶ gRPC Client ──▶ gRPC Server (duplicate contract, extra code, extra latency) With gRPC-Web: Browser ──gRPC-Web──▶ Envoy ──native gRPC──▶ gRPC Server (same contract, no extra code on server)
What are common mistakes with gRPC-web?
The most common mistake is expecting full bidirectional streaming to work through gRPC-web, which only supports unary and server-side streaming. Another error is not enabling CORS on the Envoy proxy, causing browsers to block the request. A third mistake is forgetting that gRPC-web uses base64-encoded protobuf over HTTP/1.1, which adds overhead compared to native binary gRPC.
package com.bank.grpc;
public class GrpcWebMistakes {
public static void main(String[] args) {
System.out.println("WRONG: bidirectional streaming via gRPC-web");
System.out.println(" gRPC-web only supports unary and server-streaming");
System.out.println(" Client-streaming and bidi-streaming require WebSocket");
System.out.println();
System.out.println("WRONG: no CORS on Envoy proxy");
System.out.println(" Browser blocks preflight OPTIONS request");
System.out.println();
System.out.println("CORRECT: Envoy config with cors_filter + grpc_web filter");
System.out.println(" Unary and server-streaming only");
System.out.println(" Use REST fallback for real-time bidi use cases");
}
}
Expected output:
WRONG: bidirectional streaming via gRPC-web
gRPC-web only supports unary and server-streaming
Client-streaming and bidi-streaming require WebSocket
WRONG: no CORS on Envoy proxy
Browser blocks preflight OPTIONS request
CORRECT: Envoy config with cors_filter + grpc_web filter
Unary and server-streaming only
Use REST fallback for real-time bidi use cases
What should a senior engineer know?
In production, the Envoy proxy does double duty: it provides TLS termination, load balancing, and observability in the same hop, which is why a dedicated proxy is preferred over embedding translation inside the application. Spring Boot starters that support gRPC-Web are available, but a dedicated Envoy sidecar or edge proxy is the standard production pattern.
Visual Overview
Browser gRPC-Web Flow:
Browser (JavaScript)
│
│ gRPC-Web request (HTTP/1.1 or HTTP/2)
│ Status packed in response body
▼
┌──────────────────────────┐
│ Envoy Proxy │
│ │
│ grpc_web filter: │
│ unwrap gRPC-Web ──▶ │
│ emit native gRPC │
│ │
│ CORS filter: │
│ handle preflight │
└──────────┬───────────────┘
│ native gRPC over HTTP/2
▼
┌──────────────────────────┐
│ gRPC Backend │
│ (pure gRPC, unchanged) │
└──────────────────────────┘
Key Takeaway
Browsers cannot speak native gRPC because the fetch and XHR APIs do not expose HTTP/2 trailers, which native gRPC uses to carry the final status. gRPC-Web defines a variant wire format that packs the status into the response body so browsers can read it over HTTP/1.1 or HTTP/2. A proxy like Envoy sits in the middle: its grpc_web filter unwraps the browser's gRPC-Web request into native gRPC and wraps the response back, and a CORS filter handles cross-origin preflight.
The backend stays pure gRPC and never knows a browser is on the other end. At National Bank of India, when the new JavaScript dashboard needed to show Rajesh his balance, Pankaj did not build a separate REST layer. They generated a gRPC-Web stub, put Envoy in front of the existing gRPC balance service, and the dashboard called getBalance as if it were a native gRPC call.
The key limitation is that gRPC-Web supports unary and server streaming but not client streaming or bidirectional streaming, because browsers still cannot stream a request body frame by frame. In production, the Envoy proxy also provides TLS termination, load balancing, and observability in the same hop.
Remember one sentence: gRPC-Web lets browsers call gRPC services through Envoy, which translates the browser's HTTP/1.1 into native gRPC for the backend, so you share one protobuf contract with no REST duplication.
---