How gRPC-Web Works

gRPC-Web is a protocol adaptation layer that brings gRPC to browser environments. Standard gRPC relies on HTTP/2 features that browsers do not expose to JavaScript, so gRPC-Web defines an alternative wire format that works over HTTP/1.1 and can be consumed by browser-based clients. It bridges the gap between gRPC's efficient binary RPC model and the constraints of web platform APIs.

This article covers why gRPC cannot run natively in browsers, how gRPC-Web solves the problem, the role of translation proxies like Envoy, the Connect protocol as a modern alternative, supported RPC patterns, TypeScript/JavaScript code generation, and how gRPC-Web compares to REST and GraphQL for frontend development.

Why gRPC Does Not Work in Browsers

gRPC was designed for server-to-server communication. It builds directly on HTTP/2 with features that the browser networking stack does not expose to application code. Three fundamental limitations prevent standard gRPC from working in a browser.

No Access to HTTP/2 Framing

gRPC sends messages as length-prefixed binary frames within an HTTP/2 stream. The protocol relies on HTTP/2's multiplexing, flow control, and binary framing layer. Browser APIs like fetch() and XMLHttpRequest operate at a higher abstraction level -- they give you the response body as a single blob or a readable stream, but they do not expose the HTTP/2 framing primitives that gRPC depends on. You cannot control individual HTTP/2 frames, manage stream priorities, or send multiple interleaved requests on a single connection from JavaScript.

No HTTP/2 Trailers

gRPC uses HTTP/2 trailers to deliver the final status code and error details after the response body has been sent. This is fundamental to how gRPC works: the server streams data, and only after the last message does it send trailing metadata containing grpc-status and grpc-message. Browser APIs do not expose HTTP trailers at all. The fetch() API provides response headers (sent before the body) but has no mechanism to read trailers (sent after the body). This means a browser client has no way to receive gRPC status information in the standard format.

No Raw TCP or HTTP/2 Connections

gRPC clients in languages like Go, Java, and C++ manage their own TCP connections and HTTP/2 sessions. They open persistent connections, multiplex streams, and handle flow control directly. Browsers forbid raw socket access for security reasons. All network access goes through browser-managed APIs that enforce CORS, TLS policies, and connection pooling. A browser cannot open a raw TCP socket to a gRPC server.

Native gRPC (Server-to-Server) gRPC Client (Go/Java/C++) HTTP/2 frames HTTP/2 (multiplexed streams) trailers TCP Socket (direct control) Works Browser (JavaScript) fetch() / XMLHttpRequest no frame access Browser HTTP stack (opaque) no trailers No raw sockets (sandboxed) Blocked

The gRPC-Web Protocol

gRPC-Web is a specification maintained by the gRPC project that defines a modified wire format compatible with browser limitations. It was designed to be as close to native gRPC as possible while working within the constraints of fetch() and XMLHttpRequest.

Wire Format Differences

The gRPC-Web wire format makes several key adaptations:

Frame Structure

A gRPC-Web response body consists of one or more frames, each with this structure:

+--------+----------------+----------------------------+
| 1 byte |    4 bytes     |       N bytes              |
|  flag  | message length |   protobuf message data    |
+--------+----------------+----------------------------+

flag = 0x00: data frame (serialized protobuf message)
flag = 0x80: trailers frame (grpc-status, grpc-message, etc.)

The final frame in every response has its flag byte set to 0x80, signaling that it contains trailer metadata rather than a data message. The client reads this frame to determine whether the RPC succeeded or failed.

Content Types

gRPC-Web defines two content types:

gRPC-Web Response Body Layout 0x00 length protobuf message 1 Data Frame 0x00 length protobuf message 2 Data Frame ... 0x80 length grpc-status: 0\r\ngrpc-message: OK Trailers Data flag (0x00) Trailers flag (0x80)

The Translation Proxy: Envoy and Alternatives

Because the gRPC-Web wire format differs from native gRPC, something must translate between the two. In most deployments, a reverse proxy sits between the browser and the gRPC backend, accepting gRPC-Web requests from the browser and forwarding them as standard gRPC to the backend server.

Envoy as the gRPC-Web Gateway

The canonical deployment uses Envoy Proxy as the translation layer. Envoy has built-in support for the gRPC-Web protocol via its envoy.filters.http.grpc_web filter. When enabled, Envoy performs the following translations:

Envoy also provides load balancing across multiple gRPC backend instances, health checking, circuit breaking, and observability -- making it a production-grade gRPC-Web gateway.

Other Translation Proxies

Envoy is not the only option. Several alternatives exist:

gRPC-Web Architecture with Envoy Proxy Browser gRPC-Web Client (TypeScript/JS) HTTP/1.1 application/ grpc-web Envoy Proxy gRPC-Web filter CORS handling Trailer rewrite Load balancing HTTP/2 application/ grpc gRPC Server Native gRPC (Go/Java/Rust/...) Envoy translates gRPC-Web <-> native gRPC and adds CORS headers

The Connect Protocol: A Modern Alternative

The Connect protocol, developed by Buf, is a newer approach to the same problem. Rather than adapting gRPC's wire format for browsers, Connect defines a simpler HTTP-based protocol that works natively in browsers, standard HTTP clients like curl, and gRPC servers simultaneously.

How Connect Differs from gRPC-Web

Connect takes a different design philosophy:

Connect vs gRPC-Web: When to Choose Which

Connect is generally the better choice for new projects. Its advantages include: simpler debugging (curl-friendly), no proxy infrastructure, standard HTTP semantics, and better error handling. gRPC-Web remains relevant when you have an existing Envoy-based infrastructure, need strict compatibility with the official gRPC ecosystem, or are working with teams that have already standardized on the gRPC-Web toolchain.

Supported RPC Types

gRPC defines four RPC patterns, but gRPC-Web only supports two of them. This is a fundamental limitation imposed by browser HTTP APIs.

Unary RPC (Supported)

A single request followed by a single response -- the most common pattern. The client sends one message, the server processes it and returns one message. This maps directly to a standard HTTP request-response cycle and works perfectly in browsers.

// Proto definition
service UserService {
  rpc GetUser(GetUserRequest) returns (User);
}

// Client call
const user = await client.getUser({ id: "usr_123" });

Server Streaming (Supported with Caveats)

The client sends one request and the server returns a stream of messages. gRPC-Web supports this, but the implementation varies by transport. With the application/grpc-web-text content type, the client can read base64-encoded chunks as they arrive using the Fetch API's ReadableStream. With the binary format, some environments buffer the entire response before making it available.

// Proto definition
service StockService {
  rpc WatchPrice(WatchRequest) returns (stream PriceUpdate);
}

// Client call
for await (const update of client.watchPrice({ symbol: "AAPL" })) {
  console.log(update.price);
}

Client Streaming (Not Supported)

The client sends a stream of messages followed by a single server response. This is not possible in browsers because fetch() does not support streaming request bodies in a way that gRPC-Web can use. The request body must be fully available when the request is initiated. While the Fetch API has added support for ReadableStream request bodies in some browsers, this is not yet universally available and gRPC-Web does not use it.

Bidirectional Streaming (Not Supported)

Both client and server stream messages simultaneously. This requires full-duplex communication on a single HTTP connection, which browsers do not support through standard HTTP APIs. WebSockets provide full-duplex communication, but gRPC-Web does not use WebSockets -- it stays within the HTTP request-response model.

gRPC-Web: Supported RPC Patterns RPC TYPE PATTERN gRPC-WEB CONNECT Unary req res Yes Yes Server Streaming req stream Partial Yes Client Streaming stream res No No Bidirectional Streaming both stream No No

Code Generation for TypeScript and JavaScript

Like native gRPC, gRPC-Web uses code generation from Protocol Buffer definitions to create type-safe client stubs. The code generator reads your .proto files and produces JavaScript or TypeScript classes that handle serialization, deserialization, and the gRPC-Web transport.

The Official grpc-web Generator

The official grpc-web package from the gRPC project uses protoc with the protoc-gen-grpc-web plugin. The generated code provides a service client class with methods matching your proto service definition:

// Generated from user.proto
import { UserServiceClient } from './proto/user_grpc_web_pb';
import { GetUserRequest } from './proto/user_pb';

const client = new UserServiceClient('https://api.example.com');

const request = new GetUserRequest();
request.setId('usr_123');

client.getUser(request, {}, (err, response) => {
  if (err) {
    console.error(err.message);
    return;
  }
  console.log(response.getName());
  console.log(response.getEmail());
});

The official generator produces code that uses getter/setter methods (like getName() and setName()) rather than plain object properties. This style is verbose and does not feel natural in TypeScript. The generated types also lack idiomatic TypeScript interfaces.

Buf and Connect-ES: Modern Code Generation

The Buf CLI and Connect-ES offer a significantly better developer experience. Connect-ES generates idiomatic TypeScript with plain object types, Promises instead of callbacks, and full type inference:

// Generated with Connect-ES from user.proto
import { createClient } from "@connectrpc/connect";
import { createGrpcWebTransport } from "@connectrpc/connect-web";
import { UserService } from "./gen/user_connect";

const transport = createGrpcWebTransport({
  baseUrl: "https://api.example.com",
});

const client = createClient(UserService, transport);

// Type-safe, Promise-based, plain objects
const user = await client.getUser({ id: "usr_123" });
console.log(user.name);   // string
console.log(user.email);  // string

Connect-ES also supports server streaming with async iterators, interceptors for adding authentication headers, and automatic retry logic. The transport layer is pluggable -- you can switch between gRPC-Web and Connect protocol transports without changing your application code.

Buf CLI and the Buf Schema Registry

The Buf CLI (buf) replaces protoc as the tool for working with Protocol Buffers. It provides several advantages:

The Buf Schema Registry (BSR) is a hosted registry for Protobuf schemas, analogous to npm for JavaScript or crates.io for Rust. Teams publish their proto definitions to the BSR, and consumers can depend on specific versions. The BSR also generates documentation, provides a web UI for browsing schemas, and can generate client SDKs on the fly in multiple languages.

# buf.gen.yaml - Code generation configuration
version: v2
plugins:
  - remote: buf.build/connectrpc/es
    out: src/gen
    opt: target=ts
  - remote: buf.build/bufbuild/es
    out: src/gen
    opt: target=ts

# Generate TypeScript clients
$ buf generate

CORS Considerations

Because gRPC-Web clients run in browsers, every request to a gRPC backend is subject to the browser's Cross-Origin Resource Sharing (CORS) policy. If your gRPC-Web client is served from app.example.com and your gRPC backend is at api.example.com, the browser will send a CORS preflight OPTIONS request before each gRPC-Web call.

The proxy (Envoy, Connect server, etc.) must respond to preflight requests with the appropriate headers:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, X-Grpc-Web, Authorization
Access-Control-Expose-Headers: Grpc-Status, Grpc-Message
Access-Control-Max-Age: 86400

gRPC-Web always uses HTTP POST, so GET requests cannot be used for simple CORS-exempt calls. Every gRPC-Web request triggers a preflight unless the proxy is on the same origin as the frontend. This is one advantage of the Connect protocol for unary calls -- Connect supports GET requests for idempotent methods, which can avoid CORS preflight overhead and benefit from HTTP caching.

gRPC-Web vs REST+JSON vs GraphQL

When building a web frontend that communicates with backend services, three main approaches dominate: REST with JSON, GraphQL, and gRPC-Web (or Connect). Each makes different tradeoffs.

Type Safety and Code Generation

gRPC-Web generates fully typed client code from .proto definitions. The types are derived from the schema, so the compiler catches mismatches between client and server. REST APIs typically require manually-written TypeScript types or use OpenAPI codegen, which is often incomplete. GraphQL has strong typing through its schema, and tools like GraphQL Code Generator produce TypeScript types, but the type system is different from Protobuf -- it lacks features like enums with numeric values and fixed-size integers.

Payload Size and Performance

Protocol Buffers, used by gRPC-Web, produce significantly smaller payloads than JSON. A typical protobuf message is 3-10x smaller than its JSON equivalent because field names are replaced with small integer tags and values use efficient binary encoding. For high-frequency or large-payload APIs, this can meaningfully reduce bandwidth and parsing overhead. JSON (used by REST and GraphQL) is human-readable but verbose.

API Design

REST encourages a resource-oriented design: GET /users/123, POST /orders, PATCH /users/123. gRPC uses a service-oriented design: UserService.GetUser, OrderService.CreateOrder. GraphQL uses a query-oriented design where the client specifies exactly which fields it wants. Each style suits different architectures. gRPC's service model maps well to microservice architectures where backend services already use gRPC internally.

Streaming

gRPC-Web supports server streaming, allowing the server to push a sequence of messages to the client over a single connection. REST has no native streaming support (you would use WebSockets or SSE separately). GraphQL has subscriptions, but they require a separate WebSocket transport and are not part of the core HTTP-based query model. For real-time features like live data feeds, gRPC-Web server streaming is more tightly integrated than REST alternatives.

Tooling and Ecosystem

REST has the largest ecosystem -- every HTTP client, testing tool, and CDN understands it natively. GraphQL has strong frontend tooling (Apollo, Relay, urql) with caching, optimistic updates, and pagination built in. gRPC-Web's ecosystem is smaller but growing, especially with the Buf toolchain. A key difference: gRPC-Web requests are opaque binary in the default mode, making them harder to debug in browser DevTools compared to JSON-based protocols. The Connect protocol addresses this by supporting JSON encoding.

Frontend Protocol Comparison DIMENSION REST+JSON GRAPHQL gRPC-WEB Type Safety Manual / OpenAPI Schema-derived Generated code Payload Size Large (JSON) Medium (JSON) Small (protobuf) Streaming SSE / WebSocket Subscriptions Server streaming Proxy Needed No No Yes (or Connect) DevTools Debug Excellent Good Opaque binary Ecosystem Ubiquitous Large Growing Schema Evolution Versioned URLs Additive fields Protobuf compat Best For Public APIs, broad compat Data-rich UIs, flexible queries Microservices, perf-critical

Real-World Architecture Patterns

There are several common patterns for deploying gRPC-Web in production. The right choice depends on your existing infrastructure, team expertise, and performance requirements.

Pattern 1: Envoy Sidecar

Each gRPC service runs alongside an Envoy sidecar that handles gRPC-Web translation. This is common in Kubernetes deployments using service mesh architectures like Istio (which uses Envoy as its data plane proxy). The sidecar handles TLS termination, gRPC-Web translation, and observability. The application server only needs to implement standard gRPC.

Pattern 2: Edge Gateway

A single Envoy or API gateway at the edge of the network handles gRPC-Web translation for all backend services. The browser talks to the gateway over HTTP/1.1 or HTTP/2 with gRPC-Web encoding, and the gateway forwards requests to the appropriate backend service using native gRPC over the internal network. This centralizes the gRPC-Web configuration and CORS handling.

Pattern 3: Connect Server (No Proxy)

With the Connect protocol, the backend server handles gRPC-Web, gRPC, and Connect natively. No proxy is needed. The server uses a Connect framework (available in Go, Node.js, Java, and other languages) that speaks all three protocols. This is the simplest architecture and eliminates an entire infrastructure component.

Pattern 4: BFF (Backend for Frontend)

A dedicated Backend for Frontend service sits between the browser and the gRPC microservices. The BFF exposes a REST or GraphQL API to the browser and calls gRPC services internally. This avoids gRPC-Web entirely on the client side, at the cost of maintaining an additional service layer. This pattern is useful when the frontend needs to aggregate data from multiple gRPC services into a single response.

Deployment Patterns Envoy Sidecar Browser Envoy gRPC Svc Edge Gateway Browser Gateway (Envoy) Svc A Svc B Connect Server (No Proxy) Browser direct Connect Server Speaks gRPC + gRPC-Web + Connect BFF Pattern Browser REST BFF gRPC Svc A Svc B Recommendation for New Projects Use Connect protocol. No proxy needed. Speaks gRPC, gRPC-Web, and Connect. curl-friendly. Simplest architecture. Existing gRPC Infrastructure Use Envoy with gRPC-Web filter. No backend changes needed. Already have service mesh? Use it.

Setting Up a gRPC-Web Project

Here is a concrete walkthrough for setting up a gRPC-Web frontend using the modern Buf toolchain.

Step 1: Define Your Proto Schema

// proto/todo/v1/todo.proto
syntax = "proto3";

package todo.v1;

message Todo {
  string id = 1;
  string title = 2;
  bool completed = 3;
}

message ListTodosRequest {}

message ListTodosResponse {
  repeated Todo todos = 1;
}

message CreateTodoRequest {
  string title = 1;
}

service TodoService {
  rpc ListTodos(ListTodosRequest) returns (ListTodosResponse);
  rpc CreateTodo(CreateTodoRequest) returns (Todo);
  rpc WatchTodos(ListTodosRequest) returns (stream Todo);
}

Step 2: Configure Buf

# buf.yaml
version: v2
modules:
  - path: proto

# buf.gen.yaml
version: v2
plugins:
  - remote: buf.build/bufbuild/es
    out: src/gen
    opt: target=ts
  - remote: buf.build/connectrpc/es
    out: src/gen
    opt: target=ts

Step 3: Generate Code and Use It

# Generate TypeScript stubs
$ buf generate

# Install runtime dependencies
$ npm install @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuf
// src/client.ts
import { createClient } from "@connectrpc/connect";
import { createGrpcWebTransport } from "@connectrpc/connect-web";
import { TodoService } from "./gen/todo/v1/todo_connect";

const transport = createGrpcWebTransport({
  baseUrl: "https://api.example.com",
});

const client = createClient(TodoService, transport);

// List todos
const { todos } = await client.listTodos({});

// Create a new todo
const newTodo = await client.createTodo({ title: "Write article" });

// Watch for updates (server streaming)
for await (const todo of client.watchTodos({})) {
  console.log("Updated:", todo.title, todo.completed);
}

Error Handling and Status Codes

gRPC-Web uses the same status code system as native gRPC. The grpc-status trailer (embedded in the response body for gRPC-Web) carries a numeric code, and grpc-message carries a human-readable description.

Common status codes:

In Connect-ES, errors are thrown as ConnectError instances with typed code properties, making error handling natural in TypeScript:

import { ConnectError, Code } from "@connectrpc/connect";

try {
  await client.getUser({ id: "nonexistent" });
} catch (err) {
  if (err instanceof ConnectError && err.code === Code.NotFound) {
    console.log("User not found");
  }
}

Performance Considerations

Several factors affect gRPC-Web performance in browser environments:

Security

gRPC-Web inherits the security properties of HTTPS. All gRPC-Web traffic should use TLS in production. Authentication is typically handled via metadata headers -- the gRPC equivalent of HTTP headers.

Common authentication patterns:

When to Use gRPC-Web

gRPC-Web is the right choice when:

gRPC-Web is less appropriate for public-facing APIs where ease of integration matters more than performance, or for teams without existing Protobuf infrastructure. For those cases, REST with OpenAPI or GraphQL provides a lower barrier to entry.

The Broader Ecosystem

gRPC-Web exists within a larger ecosystem of tools built around Protocol Buffers and gRPC. Understanding how these pieces fit together helps when evaluating the technology:

The trend is toward simplification: the Connect protocol and Buf toolchain eliminate much of the complexity that made gRPC-Web adoption difficult in its early years. New projects can adopt gRPC-Web with significantly less infrastructure overhead than was required even two years ago.

See BGP routing data in real time

Open Looking Glass
More Articles
How gRPC Works
How Protocol Buffers Work
gRPC Load Balancing: Strategies and Patterns
gRPC and Service Mesh: Istio, Envoy, and Linkerd
gRPC Security: Authentication, TLS, and Authorization
gRPC Reflection, Testing, and Debugging