Skip to content
gohacks.dev
APIs

gRPC Web and Browser Clients Talking to Go Servers

Serve browsers from a Go gRPC backend: the gRPC-Web wire format frame by frame, status in trailers, CORS preflight headers, streaming limits and options.

12 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Everything in this series so far assumes a client that can speak native gRPC: another Go service, a Java service, a mobile app. A web page cannot. The browser's fetch API does not let JavaScript choose HTTP/2 framing, does not give access to response trailers, and does not allow the request/response streaming patterns gRPC relies on. A gRPC server cannot be called directly from JavaScript running in a page, however good the generated client is.

Tested with Go 1.25.6, connect-go v1.18.1, connectrpc.com/cors v0.1.0, rs/cors v1.11.1, macOS on Apple Silicon (arm64)

gRPC-Web is the protocol that fills the gap. It is gRPC with the parts browsers cannot do changed: messages are framed in a way fetch can carry, status and trailers travel in the body, and the content type says so. A server or proxy translates between it and native gRPC. This article shows the wire format byte by byte, what a Go server needs to serve it, the CORS configuration browsers require, and the limits of what you can do from a page. It builds on Connect RPC, gRPC and Connect compared and authentication. It ran on Go 1.25.6.

Why browsers are different

Native gRPC needs three things that a page cannot provide.

  1. HTTP/2 trailers. A gRPC response ends with trailers carrying grpc-status and grpc-message. fetch exposes response headers, not trailers.
  2. Control of framing and streams. gRPC uses HTTP/2 DATA frames with its own message framing; fetch gives you a byte stream, and for requests, historically, only a complete body.
  3. A content type and headers that trigger CORS. application/grpc is not a CORS-safelisted type, so every call is preceded by a preflight, and the servers must allow the right headers.

gRPC-Web changes (1) and (2). The status goes into a trailers frame at the end of the body, so no actual trailers are needed, and the messages use a framing that works over HTTP/1.1 or HTTP/2.

The wire format

Every gRPC-Web body, request and response, is a sequence of frames. A frame is five bytes of header followed by a payload: one flag byte, then a four byte big-endian length.

  • Flag 0x00: a data frame whose payload is one serialised protobuf message.
  • Flag 0x80: a trailers frame whose payload is text lines in HTTP header form, like grpc-status: 0 followed by a carriage return and a line feed.

The content type is application/grpc-web+proto (or +json). There is also a text variant, application/grpc-web-text, which base64-encodes the same bytes, for older browser transports that could not handle binary responses.

The lab builds a request by hand, which is the clearest way to see this. A StringValue message containing "browser" is marshalled with proto.Marshal, prefixed with a flag byte of 0 and its length, and posted to a Connect handler with the right content type. The handler is the same kind used in the Connect article, served here with a CORS wrapper.

main.goGo
package main

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/binary"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

	"connectrpc.com/connect"
	connectcors "connectrpc.com/cors"
	"github.com/rs/cors"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/types/known/wrapperspb"
)

const method = "/greet.v1.Greeter/Greet"

func frame(flag byte, payload []byte) []byte {
	out := make([]byte, 5, 5+len(payload))
	out[0] = flag
	binary.BigEndian.PutUint32(out[1:], uint32(len(payload)))
	return append(out, payload...)
}

// describe walks the gRPC-Web frames of a response body.
func describe(body []byte) {
	for len(body) >= 5 {
		flag, n := body[0], binary.BigEndian.Uint32(body[1:5])
		payload := body[5 : 5+n]
		body = body[5+n:]
		if flag&0x80 != 0 {
			fmt.Printf("    frame flag=0x%02x (trailers) %d bytes: %q\n", flag, n, string(payload))
			continue
		}
		var msg wrapperspb.StringValue
		proto.Unmarshal(payload, &msg)
		fmt.Printf("    frame flag=0x%02x (message)  %d bytes: StringValue %q\n", flag, n, msg.GetValue())
	}
}

func main() {
	greet := connect.NewUnaryHandler(method, func(_ context.Context, r *connect.Request[wrapperspb.StringValue]) (*connect.Response[wrapperspb.StringValue], error) {
		if r.Msg.GetValue() == "" {
			return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("name is required"))
		}
		return connect.NewResponse(wrapperspb.String("Hello, " + r.Msg.GetValue())), nil
	})
	mux := http.NewServeMux()
	mux.Handle(method, greet)

	// The same handler, wrapped in CORS for browsers on https://app.example.test.
	allowed := cors.New(cors.Options{
		AllowedOrigins: []string{"https://app.example.test"},
		AllowedMethods: connectcors.AllowedMethods(),
		AllowedHeaders: connectcors.AllowedHeaders(),
		ExposedHeaders: connectcors.ExposedHeaders(),
		MaxAge:         7200,
	}).Handler(mux)
	srv := httptest.NewServer(allowed)
	defer srv.Close()

	post := func(contentType string, body []byte, origin string) (*http.Response, []byte) {
		req, _ := http.NewRequest("POST", srv.URL+method, bytes.NewReader(body))
		req.Header.Set("Content-Type", contentType)
		req.Header.Set("X-Grpc-Web", "1")
		if origin != "" {
			req.Header.Set("Origin", origin)
		}
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		return resp, b
	}

	// 1. A hand-built gRPC-Web request: one length-prefixed protobuf message.
	msg, _ := proto.Marshal(wrapperspb.String("browser"))
	resp, body := post("application/grpc-web+proto", frame(0, msg), "")
	fmt.Println("gRPC-Web binary request ->", resp.StatusCode, resp.Header.Get("Content-Type"))
	describe(body)

	// 2. The error case: the status travels in the trailers frame, while the HTTP status stays 200.
	empty, _ := proto.Marshal(wrapperspb.String(""))
	resp, body = post("application/grpc-web+proto", frame(0, empty), "")
	fmt.Println("gRPC-Web, invalid argument ->", resp.StatusCode, "(the HTTP status is 200; the failure is in grpc-status)")
	fmt.Printf("    grpc-status header: %q, grpc-message header: %q, body bytes: %d\n", resp.Header.Get("Grpc-Status"), resp.Header.Get("Grpc-Message"), len(body))
	describe(body)

	// 3. gRPC-Web text mode: the same frames, base64 encoded, for older browser transports.
	resp, body = post("application/grpc-web-text", []byte(base64.StdEncoding.EncodeToString(frame(0, msg))), "")
	raw, _ := base64.StdEncoding.DecodeString(string(body))
	fmt.Println("gRPC-Web text request ->", resp.StatusCode, "(connect-go serves binary grpc-web only; the web client uses fetch and needs no base64 mode)")
	describe(raw)

	// 4. What a browser does before the call: a CORS preflight.
	req, _ := http.NewRequest("OPTIONS", srv.URL+method, nil)
	req.Header.Set("Origin", "https://app.example.test")
	req.Header.Set("Access-Control-Request-Method", "POST")
	req.Header.Set("Access-Control-Request-Headers", "connect-protocol-version,content-type,x-grpc-web,x-user-agent") // browsers send this list sorted
	pre, _ := http.DefaultClient.Do(req)
	pre.Body.Close()
	fmt.Println("\nCORS preflight from the allowed origin ->", pre.StatusCode)
	fmt.Println("    allow-origin :", pre.Header.Get("Access-Control-Allow-Origin"))
	fmt.Println("    allow-headers:", pre.Header.Get("Access-Control-Allow-Headers"))
	req.Header.Set("Origin", "https://evil.example")
	pre, _ = http.DefaultClient.Do(req)
	pre.Body.Close()
	fmt.Println("CORS preflight from another origin, allow-origin header:", strings.TrimSpace(fmt.Sprintf("%q", pre.Header.Get("Access-Control-Allow-Origin"))))
	resp, _ = post("application/grpc-web+proto", frame(0, msg), "https://app.example.test")
	fmt.Println("actual call exposes headers:", resp.Header.Get("Access-Control-Expose-Headers"))
}
Text
gRPC-Web binary request -> 200 application/grpc-web+proto
    frame flag=0x00 (message)  16 bytes: StringValue "Hello, browser"
    frame flag=0x80 (trailers) 16 bytes: "grpc-status: 0\r\n"
gRPC-Web, invalid argument -> 200 (the HTTP status is 200; the failure is in grpc-status)
    grpc-status header: "3", grpc-message header: "name is required", body bytes: 0
gRPC-Web text request -> 415 (connect-go serves binary grpc-web only; the web client uses fetch and needs no base64 mode)

CORS preflight from the allowed origin -> 204
    allow-origin : https://app.example.test
    allow-headers: connect-protocol-version,content-type,x-grpc-web,x-user-agent
CORS preflight from another origin, allow-origin header: ""
actual call exposes headers: Grpc-Status, Grpc-Message, Grpc-Status-Details-Bin

The success response

The first block of output shows the reply to a successful call: HTTP 200 with content type application/grpc-web+proto, and two frames. The first has flag 0x00, 16 bytes, and decodes as StringValue "Hello, browser". The second has flag 0x80, 16 bytes, and its text is grpc-status: 0 followed by a line break. That trailers frame is how the browser learns that the call ended well. A real client library reads frames as bytes arrive from the response body stream, decodes data frames into messages, and treats the trailers frame as the end of the call.

The failure response

The second call sent an empty name, which the handler rejects with InvalidArgument. The output shows something that surprises people: the HTTP status is still 200. The failure is in grpc-status: 3 and grpc-message: name is required, which this server sent as response headers with an empty body (a "trailers-only" response, which gRPC-Web allows when there is no message to send). Whether the status is in headers or in a trailers frame depends on the server and whether a message was sent first; a client library handles both.

The consequence for everything around the server: HTTP status codes tell you nothing about gRPC-Web call outcomes. Load balancers, CDNs, uptime monitors and browser developer tools show 200 for every failed call. Monitor the gRPC status at the server (an interceptor or the Connect equivalent that counts codes), not the HTTP status at the edge.

The text mode

The third request used application/grpc-web-text and got a 415 Unsupported Media Type. The Connect handler serves binary gRPC-Web only. That is fine in practice. The base64 mode existed for browsers whose XHR could not read binary responses; with fetch and streams, binary works everywhere that matters. If you need text mode (some older client libraries default to it), use Envoy or the grpcweb Go wrapper, which support it.

CORS: the part that breaks first

A page on https://app.example.test calling an API on another origin triggers a preflight OPTIONS request, because gRPC-Web uses a non-simple content type and custom headers. The server must answer it with the right Access-Control-Allow-* headers, or the browser refuses to send the real call and logs a CORS error that says nothing about gRPC.

The Connect project publishes the lists you need in connectrpc.com/cors: allowed methods, allowed request headers (Content-Type, Connect-Protocol-Version, Connect-Timeout-Ms, Grpc-Timeout, X-Grpc-Web, X-User-Agent) and exposed response headers (Grpc-Status, Grpc-Message, Grpc-Status-Details-Bin). Pass them to whichever CORS middleware you use; the lab uses rs/cors.

The preflight output shows the result: status 204, Access-Control-Allow-Origin set to the page's origin, and Access-Control-Allow-Headers echoing the requested headers (connect-protocol-version,content-type,x-grpc-web,x-user-agent). A request from a different origin got no allow-origin header, so a browser would block it. The actual call's response lists the exposed headers, which JavaScript needs: without Grpc-Status in Access-Control-Expose-Headers, the page cannot read the status from a trailers-only response.

Two details worth knowing. List only the origins that should call the API; never reflect the request's origin, and avoid * if credentials are involved (the same rule as REST CORS). And a note from the lab itself: my first hand-written preflight listed the requested headers in an arbitrary order, and the CORS library rejected it, with no error, only a missing allow-origin header. Browsers send Access-Control-Request-Headers sorted alphabetically, and the library expects that. If you test preflights by hand, sort the list.

How to serve gRPC-Web from Go

There are four common arrangements.

A Connect handler serves gRPC-Web, gRPC and the Connect protocol on the same route, as the lab does. It is the least moving parts: one Go binary, net/http middleware for CORS and authentication, no proxy. It is the arrangement I would use for a new service.

The grpcweb wrapper (github.com/improbable-eng/grpc-web/go/grpcweb) wraps an existing grpc.Server as an http.Handler that accepts gRPC-Web requests and forwards them to the server in process. It is the least change for an existing grpc-go service. It handles the text mode and websocket transport. I have not run it here.

Envoy with the grpc_web HTTP filter translates gRPC-Web to gRPC in front of unchanged backends. It is the standard when you already run Envoy or a mesh, and the right choice when the backend is not Go.

A REST gateway (grpc-gateway) is not gRPC-Web at all: the browser speaks plain JSON over REST. It trades the typed generated client for plain fetch calls and conventional URLs.

Client libraries

On the JavaScript side, @connectrpc/connect-web generates typed clients for Connect, gRPC-Web and gRPC from the same .proto files, and works with the Go handler above; the older grpc-web package from the gRPC project generates a different style of client for the gRPC-Web protocol. Both give you promise-based unary calls and async iterables for server streams. Pick one and keep the generated code in the front end repository's build, with a CI step to regenerate and diff (code generation).

What browsers cannot do

No client streaming and no bidirectional streaming. A page can send a request body and read a streaming response, but browsers do not allow full-duplex streaming over fetch in a way gRPC-Web can rely on. Unary and server streaming work. If you need two-way, use a WebSocket (WebSocket protocol design) or server streaming plus separate unary calls for the upstream direction.

No control over connections. The browser decides how many connections to open and when. Keepalive and load balancing are the browser's and your edge's business.

Cookies and credentials follow browser rules. With a bearer token in the Authorization header, the page must send it and the preflight must allow it. With cookies, you need credentials: "include", an exact allowed origin and CSRF protection; a gRPC-Web call is a POST with a non-simple content type, which a cross-site form cannot send, but do not rely on that alone.

Compression and size limits apply as for any HTTP call; keep messages modest, and paginate lists.

Debugging a gRPC-Web call

When something goes wrong from a page, work from the outside in.

  1. Open the network tab. Is there an OPTIONS request before the POST? If the preflight failed, there is no POST, and the console error mentions CORS. Compare the preflight's response headers with the ones the browser asked for.
  2. Look at the response content type. application/grpc-web+proto means the server understood. text/html or application/json usually means a proxy or a login page answered instead.
  3. Read grpc-status. It is in the response headers for trailers-only replies and in the last frame otherwise. Status 0 with an empty body can be a legitimate empty message.
  4. Reproduce with curl. Binary framing is awkward, but Connect also accepts the Connect protocol, which curl can call with a JSON body and shows an ordinary status code and error JSON. If the Connect call works and the gRPC-Web call does not, the problem is in framing or headers, not in the handler.
  5. Check for buffering. If a server stream seems to arrive all at once at the end, something in between (a CDN, an Nginx without proxy_buffering off, a corporate proxy) is buffering the response.

Security notes specific to browsers

A browser makes your API reachable from any web page the user visits, so the controls that rarely matter between servers matter here.

CORS is not authentication. It only tells browsers which pages may read responses. A hostile script on a page that is allowed, or a non-browser client, ignores it. Every call still needs authentication and authorisation on the server (authentication).

Prefer bearer tokens to ambient cookies for cross-origin APIs, so the call carries explicit credentials that another site's page cannot trigger. If you use cookies, mark them SameSite=Lax or Strict, require a custom header (the X-Grpc-Web header is one, and it forces a preflight), and check the Origin header on the server.

Keep tokens out of URLs and logs. Metadata keys like authorization are redacted by good logging setups, URLs are not (logging without leaking secrets).

Do not leave reflection on for a service the public can reach (health and reflection); the schema is useful to attackers.

Rate limit and size-limit at the edge. A page, or anyone with the endpoint, can call it in a loop, and a message size limit protects the server (message size limits).

Running it for real

Put it behind TLS, serve over HTTP/2 where you can, set server timeouts, and apply the same authentication interceptor as for native clients, so there is one policy (interceptors). Make sure any CDN or load balancer in front does not buffer streaming responses or strip the exposed headers. Log gRPC statuses, since the HTTP status will not. Test with a real browser at least once per release: CORS misconfiguration and buffering proxies do not show up in any Go test, and they are the usual reasons for "it works with curl and not in the page". The wire format is specified in the gRPC gRPC-Web protocol document, and the Connect side is documented at connectrpc.com.

Also inWeb
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.