Home Posts gRPC-Web Tutorial: High-Performance Browser RPC [2026]
System Architecture

gRPC-Web Tutorial: High-Performance Browser RPC [2026]

gRPC-Web Tutorial: High-Performance Browser RPC [2026]
Dillip Chowdary
Dillip Chowdary
Tech Entrepreneur & Innovator · May 07, 2026 · 9 min read

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.

  1. Add a listener for browser traffic.
  2. Enable the grpc_web HTTP filter.
  3. Enable cors so preflight requests succeed.
  4. 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
Watch out: If your browser app is served from a different origin, missing CORS headers will fail before your RPC ever reaches Envoy. Preflight success is part of the happy path, not an optional hardening step.

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.

Pro tip: Keep two generation targets when your app mixes RPC styles: grpcweb for hot unary paths and grpcwebtext only for the services that truly need server streaming.

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.

  1. Create a client that targets http://localhost:8080.
  2. Build the request message.
  3. 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.

  1. Start the backend and confirm it listens on :9090.
  2. Start Envoy and confirm it listens on :8080.
  3. Load the page, click the button, and confirm the DOM shows the RPC result.
  4. 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

  1. 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.
  2. 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.
  3. 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? +
Not in the normal production model. The official gRPC-Web docs describe a proxy-based approach because browser APIs do not expose native gRPC transport behavior the same way server runtimes do. In practice, the browser talks gRPC-Web and a bridge such as Envoy forwards native gRPC upstream.
Should I use grpcweb or grpcwebtext? +
Use grpcweb for unary-heavy paths because it sends 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? +
No. The official grpc-web project currently supports unary RPCs and server-side streaming, with server streaming available only in grpcwebtext mode. Client-side and bidirectional streaming are not currently supported.
Why do I need Envoy if my backend already speaks gRPC? +
Because the backend speaking gRPC is only half of the path. The browser still needs a protocol bridge that can accept web-compatible requests, translate them, and forward them over native gRPC with HTTP/2 upstream. Envoy provides that bridge with the dedicated grpc_web filter.

Get Engineering Deep-Dives in Your Inbox

Weekly breakdowns of architecture, security, and developer tooling — no fluff.

Found this useful? Share it.