gRPC-Web Tutorial: High-Performance Browser RPC [2026]
Bottom Line
For browser clients, the fastest production path is native gRPC on the server, Envoy as the bridge, and mode=grpcweb for unary calls. Switch to grpcwebtext only when you need server streaming, because the official runtime supports streaming only in text mode.
Key Takeaways
- ›Use Envoy’s envoy.filters.http.grpc_web filter between the browser and your gRPC server
- ›Pick mode=grpcweb for unary performance; use grpcwebtext for server streaming
- ›Your upstream cluster must speak HTTP/2, or the bridge cannot forward native gRPC correctly
- ›CORS must allow content-type, x-grpc-web, and x-user-agent
Browser apps still cannot speak native gRPC the way server runtimes do, so production teams usually insert a translation layer between the browser and the backend. gRPC-Web is that bridge. In this walkthrough, you will define a small service, run a native gRPC backend, place Envoy in front of it, generate browser stubs, and verify the full request path with the mode choices that matter most for performance.
Prerequisites
- A backend service that already speaks native gRPC, or a local demo service you can run on :9090
- Envoy available locally or in Docker
- protoc, protoc-gen-js, and protoc-gen-grpc-web on your PATH
- A frontend app or static page that can reach Envoy on http://localhost:8080
- A bundler capable of handling generated client files
This flow aligns with the official gRPC-Web README, the gRPC Web basics tutorial, and Envoy’s gRPC-Web filter documentation.
Bottom Line
Use Envoy as the browser-facing bridge, keep your upstream connection on HTTP/2, and default to mode=grpcweb for unary-heavy apps. Move to grpcwebtext only when you need server streaming.
Step 1: Define the Service
Start with a minimal contract. The browser never talks to this file directly, but the entire toolchain depends on it: server implementation, Envoy route path, and generated browser stubs.
1. Create the proto
syntax = 'proto3';
package demo;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
2. Stand up a native gRPC backend
The backend can be written in any gRPC-supported language. For a compact demo, Node works well enough:
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const pkg = protoLoader.loadSync('proto/greeter.proto', {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const proto = grpc.loadPackageDefinition(pkg).demo;
function sayHello(call, callback) {
callback(null, { message: `Hello, ${call.request.name}` });
}
const server = new grpc.Server();
server.addService(proto.Greeter.service, { SayHello: sayHello });
server.bindAsync('0.0.0.0:9090', grpc.ServerCredentials.createInsecure(), () => {
server.start();
console.log('gRPC server listening on :9090');
});
Keep this backend native. gRPC-Web is not a server replacement; it is a browser transport and proxy pattern.
Step 2: Configure Envoy
This is the key architectural step. Envoy accepts browser-friendly requests, applies the gRPC-Web translation filter, and forwards native gRPC upstream over HTTP/2.
- Add a listener for browser traffic.
- Enable the grpc_web HTTP filter.
- Enable cors so preflight requests succeed.
- Configure the upstream cluster with http2protocoloptions.
admin:
address:
socket_address:
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener_0
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: ingress_http
codec_type: auto
route_config:
name: local_route
virtual_hosts:
- name: app
domains: ['*']
cors:
allow_origin_string_match:
- prefix: 'http://localhost:'
allow_methods: 'POST, OPTIONS'
allow_headers: 'content-type,x-grpc-web,x-user-agent'
expose_headers: 'grpc-status,grpc-message'
max_age: '86400'
routes:
- match:
prefix: '/'
route:
cluster: greeter_service
http_filters:
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: greeter_service
type: LOGICAL_DNS
connect_timeout: 0.25s
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: greeter_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: host.docker.internal
port_value: 9090
Step 3: Generate the Browser Client
The official grpc-web runtime supports two wire modes, and the choice matters:
- mode=grpcweb: binary protobuf, unary only
- mode=grpcwebtext: base64 text framing, unary and server streaming
For a high-performance browser RPC path, start with mode=grpcweb. The docs specify that grpcwebtext is base64-encoded, so binary mode avoids that extra encoding layer on unary calls.
1. Install the browser runtime
npm install grpc-web google-protobuf
2. Generate optimized unary stubs
mkdir -p src/gen
protoc -I=proto proto/greeter.proto \
--js_out=import_style=commonjs,binary:src/gen \
--grpc-web_out=import_style=typescript,mode=grpcweb:src/gen
The official plugin still labels import_style=typescript as experimental. If you want the most conservative path, swap it for import_style=commonjs+dts. If you publish snippets in internal docs or PR comments, clean them first with TechBytes’ Code Formatter so generated output stays readable.
Step 4: Call the Service from the Browser
Once the stubs exist, the frontend code is small. The browser points at Envoy, not the gRPC server itself.
- Create a client that targets http://localhost:8080.
- Build the request message.
- Invoke the unary RPC and render the response.
import { GreeterClient } from './gen/GreeterServiceClientPb';
import { HelloRequest } from './gen/greeter_pb';
const client = new GreeterClient('http://localhost:8080');
const output = document.getElementById('output');
export function sendHello(name) {
const req = new HelloRequest();
req.setName(name);
client.sayHello(req, {}, (err, res) => {
if (err) {
output.textContent = `RPC failed: ${err.message}`;
return;
}
output.textContent = res.getMessage();
});
}
document.getElementById('send').addEventListener('click', () => {
sendHello('Ada');
});
If you later need auth or retry logic, the official runtime also supports interceptors. Keep that out of the first implementation until the base request path is verified end to end.
Verify, Troubleshoot, and Next Steps
Verification and expected output
You want to validate three layers: backend, bridge, and browser.
- Start the backend and confirm it listens on :9090.
- Start Envoy and confirm it listens on :8080.
- Load the page, click the button, and confirm the DOM shows the RPC result.
- Open DevTools and confirm the browser sends a POST to the gRPC method path through Envoy.
gRPC server listening on :9090
# Browser UI or console output
Hello, Ada
For cross-origin setups, also verify the preflight path explicitly:
curl -i -X OPTIONS http://localhost:8080/demo.Greeter/SayHello \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-grpc-web,x-user-agent'
The response should include your configured access-control-allow-* headers. On the actual RPC, expect a browser request with application/grpc-web+proto when you use mode=grpcweb.
Troubleshooting: top 3 failures
- Preflight fails before the RPC starts. Fix the Envoy cors policy first. The common miss is forgetting x-grpc-web, x-user-agent, or content-type in allowed headers.
- The browser gets 404 or UNIMPLEMENTED. Check the fully qualified RPC path. Your package, service, and method names in the proto determine the path Envoy forwards.
- Streaming never works. The official runtime supports server streaming only with grpcwebtext. If you generated mode=grpcweb, unary will work and streaming will not.
What's next
- Add TLS on the Envoy edge before exposing this flow outside local development.
- Pass metadata for auth, tracing, and deadlines once the baseline request path is stable.
- Instrument Envoy and your backend so you can separate browser, proxy, and upstream latency.
- If you share captured request samples outside engineering, scrub payloads and headers before posting them to tickets or docs.
Frequently Asked Questions
Can browsers call native gRPC directly? +
Should I use grpcweb or grpcwebtext? +
application/grpc-web+proto in binary form. Use grpcwebtext when you need server streaming, because the official runtime supports streaming only in text mode.Does gRPC-Web support bidirectional streaming? +
Why do I need Envoy if my backend already speaks gRPC? +
Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.