Back to Advanced Topics
Q17Question 17 of 41

How do you add gRPC-web browser support (Envoy proxy translating HTTP/1.1 ↔ HTTP/2)?

Key Concepts
  • Browsers cannot speak native gRPC because they lack low-level control over HTTP/2 frames and trailers.
  • gRPC-Web defines a variant wire format that packs the status into the response body so browsers can read it over HTTP/1.1.
  • Envoy's grpc_web filter translates browser gRPC-Web calls into native gRPC over HTTP/2 for the backend.
  • A CORS filter is needed because browsers enforce cross-origin rules with preflight requests.
  • gRPC-Web supports unary and server streaming but not client or bidirectional streaming.
  • The backend stays pure gRPC and never knows a browser is on the other end.

Definition

gRPC-Web lets browsers call gRPC services by using a variant wire format, and Envoy's grpc_web filter translates between gRPC-Web and native gRPC over HTTP/2 for the backend.
  • 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_web filter unwraps gRPC-Web requests into native gRPC upstream and wraps responses back.
  • CORS filter – required because browsers enforce cross-origin rules and send preflight OPTIONS requests.
  • 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_web filter 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.
YAML
# 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.

BASH
# Generate gRPC-Web stubs for JavaScript
protoc --js_out=import_style=commonjs:./generated \
       --grpc-web_out=import_style=commonjs,mode=grpcwebtext:./generated \
       account.proto
JAVASCRIPT
// 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.

JAVA
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.

DIAGRAM
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.

JAVA
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");
    }
}
TEXT
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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate